Compare commits

..

236 Commits

Author SHA1 Message Date
dependabot[bot] 50e910ef3c Bump ruby/setup-ruby from 1.299.0 to 1.302.0
Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.299.0 to 1.302.0.
- [Release notes](https://github.com/ruby/setup-ruby/releases)
- [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb)
- [Commits](https://github.com/ruby/setup-ruby/compare/3ff19f5e2baf30647122352b96108b1fbe250c64...7372622e62b60b3cb750dcd2b9e32c247ffec26a)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 16:01:35 +00:00
Junegunn Choi 43f0508dd2 Update CHANGELOG (0.72.0)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-04-20 00:55:43 +09:00
Junegunn Choi 1986d101e0 Redraw when change-header changes line count
The inline header slot's row budget depends on header content length,
but resizeIfNeeded() tolerates a shorter-than-wanted inline window, so
the stale slot stays. Drive a redraw on length change to re-run the
layout.
2026-04-20 00:47:16 +09:00
Junegunn Choi dd7a081b93 Let inline sections take precedence over --header-first
--header-first previously was rejected with --header-border=inline or
--header-lines-border=inline. Now, inline placement wins: an inline
section stays inside the list frame, and --header-first only affects
non-inline sections (mainly the main --header).
2026-04-20 00:47:16 +09:00
Junegunn Choi d247284bc3 Set inline separator caps per side 2026-04-20 00:47:16 +09:00
Junegunn Choi 6f3e5de150 Update man page: clarify inline list-border shape requirements 2026-04-20 00:47:16 +09:00
Junegunn Choi 0142fe9d04 Update CHANGELOG: clarify inline list-border shape requirements 2026-04-20 00:47:16 +09:00
Junegunn Choi 251b08b831 Fix misleading comment on printHeader nil-window guard
The guard fires when hasHeaderWindow() returned false at resize time,
not when addInline had no budget (placeInlineStack always leaves a
non-nil 0-height placeholder).
2026-04-20 00:47:16 +09:00
Junegunn Choi 01567b4f06 Fix inline header/footer border color when falling back to line
InitTheme was called before the runtime coerced BorderInline to
BorderLine, so HeaderBorder / FooterBorder inherited from ListBorder
even when the effective shape was 'line'. Mirror the coercion so
color inheritance matches the rendered shape.
2026-04-20 00:47:16 +09:00
Junegunn Choi d754cfab87 Add --{header,header-lines,footer}-border=inline
New BorderShape that embeds the section inside the --list-border
frame, joined to the list content by a horizontal separator with
T-junctions where the list shape has side borders. Requires a list
border with both top and bottom segments; falls back to 'line'
otherwise. Stacks when multiple sections are inline.

Sections inherit --color list-border by default and are colored as a
uniform block via their own --color *-border and *-bg.

Incompatible with --header-first. --header-border=inline requires
--header-lines-border to be inline or unset.
2026-04-20 00:47:16 +09:00
Junegunn Choi ef6eba1b89 Add a test case for click-header with --header and --header-lines combined
The two sections swap order between layouts, and header-lines are
reversed under layout=default, so expected LINE values differ per
layout.
2026-04-20 00:47:16 +09:00
Junegunn Choi faa6a7e9f6 Add test cases for click-header and click-footer
Injects SGR 1006 mouse events via tmux send-keys -l to exercise
FZF_CLICK_HEADER_* / FZF_CLICK_FOOTER_* across all three layouts,
with and without a header border.
2026-04-20 00:47:16 +09:00
Junegunn Choi 8b66489987 Clean up non-ascii characters 2026-04-20 00:47:16 +09:00
Vulcalien 780a624ed7 [vim] Move and resize popup window when detecting VimResized event
Close #4778
2026-04-20 00:25:02 +09:00
Tymoteusz Makowski 1ad7c617b1 fix: add no-modifiers fallbacks
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Close #4776
2026-04-19 14:38:36 +09:00
Daniel Levin 06e70570e2 Update Makefile to support builds on Illumos
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Close #4772
2026-04-13 20:07:55 +09:00
Junegunn Choi bd4a18d0a9 Replace sort.Sort(ByOrder(...)) with slices.SortFunc
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-04-12 10:43:47 +09:00
Junegunn Choi 1b0d448c6e Update README: --tmux -> --popup 2026-04-12 09:57:26 +09:00
Junegunn Choi f584cf38f7 [bash] Persist history deletion when histappend is on
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Fix #4764

Patch suggested by @bitraid.
2026-04-11 17:03:25 +09:00
Junegunn Choi 0eb2ae9f8b Fix gutter display in --style=minimal
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-04-08 23:28:10 +09:00
Junegunn Choi 1831500018 Update README
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
I was unable to make the sponsors action to work with the new branch
protection rule. So I'm removing the sponsors section for now until
I can properly set it up again.
2026-04-05 22:20:25 +09:00
Junegunn Choi 62899fd74e 0.71.0
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
2026-04-04 14:04:34 +09:00
Junegunn Choi d1cea64a0e Allow adaptive height with negative value
Fix #4682
2026-04-04 10:13:19 +09:00
bitraid d7a132b8bf Update changelog 2026-04-04 09:48:00 +09:00
bitraid 59b82670f7 fish: Key bindings now requires fish v3.4.0+ 2026-04-04 09:47:59 +09:00
bitraid c516acaa89 Add .fish to .editorconfig shell script rules 2026-04-04 09:47:59 +09:00
bitraid 11ff4745ad Fix typo in install script 2026-04-04 09:47:59 +09:00
bitraid 23164c2263 fish: Completion script rewrite (SHIFT-TAB) 2026-04-04 09:47:59 +09:00
bitraid 9f8294de62 fish: Fixes and improvements to CTRL-R (#4703)
Fixes:
- Commands with trailing newlines
- Very long previews

Improvements:
- SHIFT-DELETE performance
- Bind ALT-T: cycle command prefix (timestamp, date/time, none)
- Set comment color for prefix
2026-04-04 09:47:59 +09:00
Junegunn Choi b7138e67a6 Update README
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-04-03 18:58:57 +09:00
Junegunn Choi 0f0751b3b6 Add CODEOWNERS
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-04-03 00:01:37 +09:00
dependabot[bot] cc16a97a40 Bump ruby/setup-ruby from 1.296.0 to 1.299.0 (#4753)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.296.0 to 1.299.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/eab2afb99481ca09a4e91171a8e0aee0e89bfedd...3ff19f5e2baf30647122352b96108b1fbe250c64)

---
updated-dependencies:
- dependency-name: ruby/setup-ruby
  dependency-version: 1.299.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-03-31 00:10:35 +09:00
dagecko 3ccf162cf0 fix: pin 6 unpinned action(s) (#4750)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-03-30 13:20:11 +09:00
PJ Eby fff7eda9d7 Terminate child processes on Windows (#4723)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Windows doesn't have signals, the default Kill doesn't actually kill
anything, and other forms of termination don't allow cleanup. So we
spawn preview processes in a new process group and send them a
CTRL_BREAK_EVENT to terminate.

However, we only do this for "pwsh" (PowerShell 7+) and unknown/
posix-ish shells, because cmd.exe and Windows PowerShell
("powershell.exe") don't always exit on Ctrl-Break. pwsh also needs
the -NonInteractive flag to exit on Ctrl-Break.

If the process wasn't given its own group, or if sending the console
control event fails, we fall back to the standard Kill (which likely
won't help, but doesn't hurt to try).

Fix #3134
2026-03-29 12:40:24 +09:00
junegunn d198c0c9de Deploying to master from @ junegunn/fzf@55d5b153e6 🚀
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-03-28 15:05:52 +00:00
harrywzl 55d5b153e6 docs: fix duplicate table rows and minor grammar/formatting in fzf.txt (#4743)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
2026-03-27 15:01:12 +09:00
Junegunn Choi 57695b0309 Remove deprecated FZF_TMUX vars from shell script headers
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-03-26 21:35:14 +09:00
Junegunn Choi 6f17d49dbb Support zellij floating pane via --popup (new name for --tmux) (#4145) 2026-03-26 21:32:56 +09:00
Junegunn Choi 87586ac5eb Only use --no-same-owner with tar when supported
Fix #4740
Fix #4741
2026-03-26 21:06:58 +09:00
Junegunn Choi 12be5e7b83 Skip adaptive height validation when --tmux overrides --height
Fix #4742
2026-03-26 20:56:36 +09:00
Qingwei Li 1acf980e95 enhancement: move server declaration to go func to allocate it in stack (#4739)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Fix #4735
Close #4736
2026-03-25 15:22:49 +09:00
cui 14e3995a06 Fix nthTransformer parts slice preallocation (#4734)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Use make([]NthParts, 0, len(indexes)) so the slice starts empty with
reserved capacity. The previous length-len(indexes) allocation left
leading zero NthParts entries before appended elements.
2026-03-23 09:55:00 +09:00
Junegunn Choi 2202481705 Add devel branch to Linux CI workflow trigger
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-03-22 10:18:17 +09:00
Junegunn Choi 6153004070 Always check hasPreviewWindow() before processing preview mouse events 2026-03-22 10:09:57 +09:00
Junegunn Choi 95f186f364 Fix preview scrollbar not rendered after toggle-preview
Fix #4732
2026-03-22 09:55:07 +09:00
Junegunn Choi 58b2855513 bash: Fix CTRL-R batch deletion
'echo {+1}' would paste multiple offsets in a line.

Fix #4730
2026-03-22 09:20:51 +09:00
Junegunn Choi a00df93e13 bash: CTRL-R now supports multi-select and batch deletion
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
- Changed +m to --multi to enable multi-select in CTRL-R
- Changed exclude to exclude-multi and {1} to {+1} so
  shift-delete removes all selected entries at once
2026-03-21 21:41:22 +09:00
Junegunn Choi 76efddd718 bash: shift-delete to delete history entries during CTRL-R
Close #4715
2026-03-21 21:10:43 +09:00
Junegunn Choi b638ff46fb Include match positions in GET / HTTP response
Close #4726
2026-03-20 23:06:16 +09:00
Junegunn Choi 259e841a77 Add pull request template with contribution policy
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-03-20 22:50:12 +09:00
Junegunn Choi f0a2f5ef14 Skip symlinks targeting ancestors of walker root to prevent resource exhaustion
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
When --walker=follow is used, symlinks like Wine's z: -> / cause fzf to
traverse the entire root filesystem. fastwalk's built-in loop detection
only catches this on the second pass, but a single pass through / already
causes severe CPU and memory exhaustion.

This fix resolves each symlink-to-directory target to its absolute real
path and skips it if it is an ancestor of (or equal to) the walker root.

Close #4710
2026-03-18 20:43:00 +09:00
Junegunn Choi 2ae7367e8a Revert "Use IgnoreDuplicateDirs to prevent duplicate directory traversal"
This reverts commit 6f33df755e.
2026-03-18 18:57:40 +09:00
Junegunn Choi 6f33df755e Use IgnoreDuplicateDirs to prevent duplicate directory traversal
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
When --walker=follow is used, symlink following is now handled by
fastwalk's IgnoreDuplicateDirs adapter which tracks visited directories
by device+inode. This prevents the same directory from being entered
more than once, avoiding effectively infinite traversal when a symlink
points outside the walker root.

Close #4710
2026-03-18 10:17:49 +09:00
Eman Resu 2aec7d5201 Fix typo in 0.71 changelog entry (#4721)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Thanks!
2026-03-17 11:45:00 +09:00
David Leadbeater fc60406684 Add "unix" and "fattr" promises (#4719)
Without this fzf --listen=/tmp/foo.sock fails on OpenBSD.
2026-03-17 11:44:13 +09:00
Junegunn Choi cf57950301 Add --id-nth to bash option completion
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-03-15 11:31:36 +09:00
Junegunn Choi 48c4913392 'reload' should not preserve multi-selection 2026-03-15 11:30:55 +09:00
Junegunn Choi 17f2aa1a1f Reorder info line: N/M (X) (S%) +S +T*
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Flags that appear/disappear are now at the end, so the multi-select
indicator stays in a fixed position and doesn't flicker.
2026-03-14 22:11:46 +09:00
Junegunn Choi b5f7221580 Replace --track=NTH with --id-nth for cross-reload item identity
Separate item identity from cursor tracking:
- Add --id-nth=NTH to define item identity fields for cross-reload ops
- --track reverts to a simple boolean flag
- track-current action no longer accepts nth argument
- With --multi, selections are preserved across reload-sync by matching
  identity keys in the reloaded list

Close #4718
Close #4701
Close #4483
Close #4409
Close #3460
Close #2441
2026-03-14 21:49:16 +09:00
Junegunn Choi e6b9a08699 Defer list rendering until pending event actions are processed 2026-03-14 20:52:04 +09:00
Junegunn Choi 8dbb3b352d Do not restart matcher during reload-sync with the intermediate list 2026-03-14 20:52:04 +09:00
Junegunn Choi 9f422851fe Add field-based tracking across reloads (--track=NTH)
Allow --track to accept an optional nth expression for cross-reload
tracking. When a reload is triggered, fzf extracts a tracking key from
the current item using the nth expression, blocks the UI, and searches
for a matching item in the reloaded list.

- --track=.. tracks by entire line, --track=1 by first field, etc.
- --track without NTH retains existing index-based behavior
- UI is blocked during search (dimmed query, hidden cursor, +T*/+t*)
- reload unblocks eagerly on match; reload-sync waits for stream end
- Escape/Ctrl-C cancels blocked state without quitting
- track-current action accepts optional nth: track-current(1)
- Validate nth expression at parse time for both --track and track()
- Cache trackKeyFor results per item to avoid redundant computation
- Rename executeRegexp to argActionRegexp

Close #4701
Close #3460
2026-03-14 20:52:04 +09:00
Junegunn Choi 7a811f0cb8 Fix --no-sort jumpiness by merging results in index order
Fix #4717
2026-03-14 20:43:30 +09:00
Evan Hahn b80059e21f Fix zsh history widget when sh_glob option is on (#4714)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
Fixes #4712.

The CTRL-R history widget failed in zsh when [zsh's `sh_glob` option][0]
was on. This fixes that by disabling the option locally, like we disable
other options.

[0]: https://zsh.sourceforge.io/Doc/Release/Options.html#:~:text=SH%5FGLOB,ksh%2E
2026-03-13 09:21:04 +09:00
Junegunn Choi 26de195bbb Update typos.toml 2026-03-13 09:08:01 +09:00
Junegunn Choi b59f27ef5a Fix --with-shell not handling quoted arguments correctly
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Fix #4709

Use go-shellwords instead of strings.Fields to parse --with-shell,
so paths with spaces can be properly quoted.

  ln -s /bin/bash "/tmp/ba sh"

  fzf --with-shell='/tmp/ba\ sh -c' --preview 'echo hello world'

  fzf --with-shell='"/tmp/ba sh" -c' --preview 'echo hello world'
2026-03-09 22:09:36 +09:00
Junegunn Choi f3ca0b1365 Fix OSC8 hyperlinks mangled when URL contains unicode
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Fix #4707
2026-03-08 13:55:14 +09:00
Junegunn Choi a8e1ef0989 Add CHANGELOG.md entry for 0.70.1 2026-03-08 11:54:56 +09:00
Junegunn Choi 2f27a3ede2 Replace []Result cache with bitmap cache for reduced memory usage
Replace the per-chunk query cache from []Result slices to fixed-size
bitmaps (ChunkBitmap: [16]uint64 = 128 bytes per entry). Each bit
indicates whether the corresponding item in the chunk matched.

This reduces cache memory by 86 times in testing:
- Old []Result cache: ~22KB per chunk per query (for 500 matches)
- New bitmap cache:   ~262 bytes per chunk per query (fixed)

With the reduced per-entry cost, queryCacheMax is raised from
chunkSize/5 to chunkSize/2, allowing broader queries (up to 50% match
rate) to be cached while still using far less memory.
2026-03-08 11:49:28 +09:00
junegunn 9249ea1739 Deploying to master from @ junegunn/fzf@92bfe68c74 🚀
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-03-07 15:02:15 +00:00
Junegunn Choi 92bfe68c74 Use a shared work queue instead of static partitioning in matcher
Replace static chunk partitioning (sliceChunks) with a shared atomic
counter that workers pull from. This gives natural load balancing;
workers that finish chunks quickly grab more work instead of idling.

With this change, NumCPU workers suffice (no need for 8x oversubscription),
reducing goroutine overhead while improving throughput by 5-22%.

Now the performance scales linearly to the number of threads:

=== query: 'linux' ===
  [all]   baseline:    17.12ms  current:    14.28ms  (1.20x)  matches: 179966 (12.79%)
  [1T]    baseline:   136.49ms  current:   137.25ms  (0.99x)  matches: 179966 (12.79%)
  [2T]    baseline:    75.74ms  current:    68.75ms  (1.10x)  matches: 179966 (12.79%)
  [4T]    baseline:    41.16ms  current:    34.97ms  (1.18x)  matches: 179966 (12.79%)
  [8T]    baseline:    32.82ms  current:    17.79ms  (1.84x)  matches: 179966 (12.79%)
2026-03-07 18:26:42 +09:00
Junegunn Choi 92dc40ea82 Print ingestion time in --bench output 2026-03-07 18:13:38 +09:00
Junegunn Choi 12a280ba14 Fix lint errors 2026-03-07 18:13:38 +09:00
Junegunn Choi 0c6ead6e98 Replace procFun map with fixed-size array for faster algo dispatch
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
termType is already a small integer enum (0-5), so a [6]algo.Algo
array avoids hash table overhead in the extendedMatch hot loop.
2026-03-07 14:19:05 +09:00
Junegunn Choi 280a011f02 With a non-default --delimiter, --{accept,with}-nth should not remove trailing whitespaces 2026-03-07 13:39:55 +09:00
Junegunn Choi d324580840 Fix AWK tokenizer not treating a new line character as whitespace 2026-03-07 11:45:02 +09:00
Junegunn Choi f9830c5a3d Fix test cases not to fail on small screens (contd.)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-03-06 19:43:16 +09:00
Junegunn Choi 95bc5b8f0c Fix test cases not to fail on small screens 2026-03-06 19:42:42 +09:00
Junegunn Choi 0b08f0dea0 Fix preview follow/scroll with long wrapped lines
Fixes bugs reported in https://github.com/junegunn/fzf/pull/4703:

* Clamp followOffset return value to avoid going past the end of lines
* Account for t.previewed.filled when determining scrollability
2026-03-06 19:21:22 +09:00
Junegunn Choi e7300fe300 Fix tab width when --frozen-left is used
https://github.com/junegunn/fzf/pull/4703#issuecomment-4004258816
2026-03-06 18:53:23 +09:00
dependabot[bot] 260d160973 Bump actions/labeler from 5 to 6 (#4700)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Bumps [actions/labeler](https://github.com/actions/labeler) from 5 to 6.
- [Release notes](https://github.com/actions/labeler/releases)
- [Commits](https://github.com/actions/labeler/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/labeler
  dependency-version: '6'
  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-03-02 23:58:37 +09:00
Laurent Cheylus d57ed157ad Remove tmppath pledge on OpenBSD (#4699)
"tmppath" pledge is no longer supported.
See commit https://github.com/openbsd/src/commit/c883e836f48a8ca9ee3be9de6ebf4b36751f2196

Signed-off-by: Laurent Cheylus <foxy@free.fr>
2026-03-02 22:55:13 +09:00
Junegunn Choi 9226bc605d Fix typos CI failure by excluding .s files 2026-03-02 22:49:54 +09:00
Junegunn Choi eacef5ea6e 0.70.0
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-03-02 16:56:13 +09:00
Junegunn Choi 96eb68ce63 Add SIMD indexByteTwo/lastIndexByteTwo doc 2026-03-02 15:49:28 +09:00
Junegunn Choi 50be8bc78e Add fuzz tests for SIMD indexByteTwo to CI
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-03-02 12:23:01 +09:00
Junegunn Choi b4e585779a Fix nth attr merge order to respect precedence hierarchy (#4697)
* Fix nth attr merge order to respect precedence hierarchy

nth attrs were merged ON TOP of current-fg/selected-fg attrs, so
nth:regular would clear attrs like underline from current-fg. Fix the
merge chain to apply nth BEFORE the line-type overlay:

  fg < nth < selected-fg < current-fg < hl < selected-hl < current-hl

Store raw current-fg and selected-fg attrs in ColorTheme before they
get merged with fg/ListFg, then pass them as nthOverlay through
printHighlighted to colorOffsets where the correct merge chain is
computed: fgAttr.Merge(nthAttr).Merge(nthOverlay).

Fix #4687

* Make current-fg inherit from list-fg instead of fg

current-fg was inheriting from fg, not list-fg, so setting list-fg
had no effect on the current line. e.g.

  fzf --color fg:dim,list-fg:underline,nth:regular -d / --nth -1

The non-nth part of the current line was dim (from fg) instead of
underline (from list-fg). Resolve ListFg/ListBg earlier in InitTheme
so Current can inherit from them.

* Make selected-fg inherit from list-fg via merge instead of override

selected-fg used o() which replaces the attr from list-fg entirely.
e.g. with fg:dim,selected-fg:italic, the dim was lost on selected
lines because o() replaced dim with italic instead of merging them.
Use ColorAttr.Merge() so attrs are combined additively, consistent
with how current-fg inherits from list-fg.

* Apply selected-fg attrs on current line when item is selected

When an item is both current and selected, the current-line rendering
ignored selected-fg attrs entirely. e.g.

  echo foo | fzf --color fg:dim,nth:regular,current-fg:underline,selected-fg:italic --bind result:select --multi

The italic from selected-fg was lost. Merge NthSelectedAttr into the
overlay and rebuild colBase attr with the correct precedence chain:
fg < selected-fg < current-fg.
2026-03-02 12:00:21 +09:00
Junegunn Choi 97ac7794cf Add SIMD indexByteTwo/lastIndexByteTwo for faster prefiltering
Use SIMD to search for two bytes simultaneously, replacing the
two-pass bytes.IndexByte approach in trySkip and the scalar backward
loop in asciiFuzzyIndex. AVX2+SSE2 on amd64, NEON on arm64, with
scalar fallback for other architectures.

=== query: 'l' ===
  [all]   baseline:   100.61ms  current:    98.88ms  (+1.7%)  matches: 5069891 (94.78%)
  [1T]    baseline:   889.28ms  current:   852.71ms  (+4.1%)  matches: 5069891 (94.78%)

=== query: 'lin' ===
  [all]   baseline:   281.31ms  current:   269.35ms  (+4.3%)  matches: 3516507 (65.74%)
  [1T]    baseline:  2266.51ms  current:  2238.24ms  (+1.2%)  matches: 3516507 (65.74%)

=== query: 'linux' ===
  [all]   baseline:    69.94ms  current:    68.33ms  (+2.3%)  matches: 307229 (5.74%)
  [1T]    baseline:   642.66ms  current:   589.10ms  (+8.3%)  matches: 307229 (5.74%)

=== query: 'linuxlinux' ===
  [all]   baseline:    39.56ms  current:    35.48ms  (+10.3%)  matches: 12230 (0.23%)
  [1T]    baseline:   367.88ms  current:   333.49ms  (+9.3%)  matches: 12230 (0.23%)

=== query: 'linuxlinuxlinux' ===
  [all]   baseline:    36.22ms  current:    31.59ms  (+12.8%)  matches: 865 (0.02%)
  [1T]    baseline:   339.48ms  current:   293.02ms  (+13.7%)  matches: 865 (0.02%)
2026-03-02 10:40:42 +09:00
Darren Bishop 4866c34361 Replace printf with builtin printf to by pass local indirections (#4684)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
On macos, having run `brew install coreutils`, which installed GNU version of `printf`, this script/completion would sometimes complain about the (now missing) `-v` option usage.

This change set ensures the `-v` option is available where needed.

Note: there is a precedent of qualify which tool to run e.g. `command find ...`, `command dirname ...`, etc, so hopefully `builtin printf ...` should not cause any offense.
2026-03-01 19:13:16 +09:00
Junegunn Choi 3cfee281b4 Add change-with-nth action to dynamically change --with-nth (#4691) 2026-03-01 16:09:54 +09:00
Junegunn Choi 5887edc6ba Use LSD radix sort for Result sorting in matcher
Replace comparison-based pdqsort with LSD radix sort on the uint64
sort key. Radix sort is O(n) vs O(n log n) and avoids pointer-chasing
cache misses in the comparison function. Sort scratch buffer is reused
across iterations to reduce GC pressure.

Benchmark (single-threaded, Chromium file list):
- linux query (180K matches): ~16% faster
- src query (high match count): ~31% faster
- Rare matches: equivalent (falls back to pdqsort for n < 128)
2026-03-01 15:57:39 +09:00
Junegunn Choi 3e751c4e87 Add direct algo fast path in matchChunk
For the common case of a single fuzzy term with no nth transform,
call the algo function directly from matchChunk, bypassing the
MatchItem -> extendedMatch -> iter dispatch chain. This eliminates
3 function calls and the per-match []Offset heap allocation.
2026-03-01 15:57:39 +09:00
Junegunn Choi 8452c78cc8 Return Result by value from MatchItem 2026-03-01 15:57:39 +09:00
Junegunn Choi 2db14b4308 Enhance --bench output with formatted times, match count, and selectivity
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-03-01 11:16:52 +09:00
junegunn 90c4269d4e Deploying to master from @ junegunn/fzf@6087055305 🚀
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-02-28 15:01:49 +00:00
Junegunn Choi 6087055305 Enable uint64 compareRanks on arm64
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
Extend the uint64 rank comparison trick (comparing [4]uint16 as a
single uint64) to arm64 builds. ARM64 is little-endian like x86, so
the same unsafe.Pointer cast produces correct lexicographic ordering.

This replaces a 4-iteration loop with a single uint64 comparison,
speeding up the sort phase.

Chromium file list, single-threaded:
  linux:  126ms -> 126ms (sort not dominant)
  src:    462ms -> 438ms (-5%, sort-heavy)
2026-02-28 14:42:28 +09:00
Junegunn Choi 2f9df91171 Add --threads option to control matcher concurrency
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
By default, fzf uses 8 * NumCPU goroutines (capped at 32) for
parallel matching. --threads N overrides this to use exactly N
goroutines, which is useful for benchmarking and profiling.
2026-02-26 14:51:59 +09:00
Junegunn Choi 12e24d368c Add --bench flag for repeatable filter-mode timing
fzf --filter PATTERN --bench 3s < input

Repeats matcher.scan() for the given duration, clears cache between
iterations, and prints stats (iterations, avg, min, max) to stderr.
2026-02-25 10:16:37 +09:00
Junegunn Choi 55193ee4dc Fix double subtraction of header lines from FZF_TOTAL_COUNT
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Close #4692
2026-02-25 00:50:47 +09:00
Junegunn Choi ff6a3bbee0 Add GitHub action for labelling PRs 2026-02-24 20:27:29 +09:00
Junegunn Choi dce248ac6d Revert "Add GitHub action for labelling PRs"
This reverts commit 0ff13dcf.
2026-02-24 20:26:39 +09:00
Junegunn Choi 0ff13dcfbe Add GitHub action for labelling PRs 2026-02-24 20:20:04 +09:00
Junegunn Choi 4d6a7757b8 Fix adaptive height calculation to exclude header lines
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
The adaptive height (--height ~100%) was using the raw chunklist count
including header items, making the window too tall by headerLines rows.
2026-02-23 02:21:41 +09:00
Junegunn Choi b9804f5873 Add change-header-lines action to dynamically change --header-lines
All input lines now enter the chunklist with sequential indices, and
header lines are excluded from matching via Pattern.startIndex and
PassMerger offset. This allows the number of header lines to be changed
at runtime with change-header-lines(N), transform-header-lines, and
bg-transform-header-lines actions.

- Remove EvtHeader event; header items are read directly from chunks
- Add startIndex to Pattern and PassMerger for skipping header items
- Add targetIndex field to Terminal for cursor repositioning across
  header-lines changes

Close #4659
2026-02-23 01:48:03 +09:00
Junegunn Choi 98a3b1fff8 Revert "Skip dead zones in FuzzyMatchV2 score matrix computation"
This reverts commit 6df5ca17e8.
2026-02-23 01:48:03 +09:00
Junegunn Choi 6df5ca17e8 Skip dead zones in FuzzyMatchV2 score matrix computation
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
In Phase 3 of FuzzyMatchV2, when a cell's left neighbor score is <= 1
and the current character doesn't match the pattern character, the
cell's score is guaranteed to be 0 (since gap penalties are -1 and -3).
Skip the bonus/gap computation entirely and fast-forward through
consecutive non-matching characters in the dead zone.

This yields 6-11% faster fuzzy searches on typical workloads.
2026-02-22 03:09:29 +09:00
Junegunn Choi 09ca45f7db Increase chunkSize from 100 to 1000 to reduce lock contention
With chunkSize=100 and 10M items, 100K chunks cause ~300K mutex
lock/unlock operations per search across 32 goroutines competing
for a single sync.Mutex in ChunkCache.

Increasing to 1000 reduces chunks to 10K, cutting contention overhead.
Benchmarks on 10M items show 14-80% faster searches depending on query
selectivity.
2026-02-22 03:09:28 +09:00
junegunn 09fe3a4180 Deploying to master from @ junegunn/fzf@b908f7a0ec 🚀
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-02-21 15:03:44 +00:00
Junegunn Choi b908f7a0ec 0.68.0
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
2026-02-20 11:03:46 +09:00
Kyle Tse 1a50a3c082 Fix FZF_COMPLETION_{DIR,PATH}_OPTS ignored with custom compgen functions (#4679)
When users define custom _fzf_compgen_path or _fzf_compgen_dir functions,
FZF_COMPLETION_PATH_OPTS and FZF_COMPLETION_DIR_OPTS were not applied
because the options were only computed inside the walker fallback branch.

Close #4592
2026-02-20 10:59:43 +09:00
Junegunn Choi fefea8d885 zsh: Make _fzf_compgen_{path,dir} respect FZF_COMPLETION_{PATH,DIR}_OPTS
Authored by: @LangLangBart

Fix #4592
2026-02-20 10:31:46 +09:00
bitraid 385cccd362 fish: Command history improvements (#4672)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
This change provides the following improvements:

- Changes the view of the command history list, so that the script no longer depends on perl for performance.
- Enables syntax color highlighting on fish v4.3.3 and newer.
- Provides a preview window with the selected commands, and expanded view of the highlighted command if available.
- Improves the delete functionality, by successfully handling very large numbers of selected commands.
- Inserts commands in their formatted form with `<Alt-Enter>`.

---

* fish: Change history list view

The view of the command history is changed, so that no manipulation is
performed on the output of history command: The history item count
number is replaced by the Unix time of the command, multi-line display
is disabled and line wrapping is enabled by default. On fish v4.3.3
and newer, the use of ANSI color codes for syntax highlighting is now
possible, while the script no longer depends on perl for performance.

Fixes #4661

* fish: Reformat selected history commands with ALT-ENTER

* Add $FZF_WRAP environment variable

The variable is set when line wrapping is enabled. It has the value
`word` if word wrapping mode is set, otherwise it has the value `char`.

* fish: Add command history preview

The preview shows the highlighted command and any selected commands,
after first being formatted and colorized by fish_indent. The timestamp
of the highlighted command in the preview is converted to strftime
format "%F %a %T" if the date command is available.

The preview is hidden on start, and is displayed if more than 100
columns are available and one of the following conditions are met:
- The highlighted item doesn't completely fit in list view (line
wrapping is enabled for the preview and is now disabled for the list).
- The highlighted item contains newlines (multi-line commands or
strings).
- The highlighted item contains chained commands in a single line, that
can be broken down by the formatter for cleaner view.
- One or more commands are marked as selected.

* fish: Handle deletion of large number of selected history entries

* fish: Change wrapping options for the preview-window
2026-02-19 23:41:26 +09:00
Junegunn Choi 4a684b6c78 Fix test failure 2026-02-19 21:37:19 +09:00
Junegunn Choi 4a195e6323 Add --preview-wrap-sign 2026-02-19 21:30:23 +09:00
Junegunn Choi 0ecbf3f475 Fix missing wrap sign at ANSI color boundary 2026-02-19 19:49:13 +09:00
Junegunn Choi 4522868fc0 Year 2026
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-02-19 00:20:09 +09:00
Junegunn Choi 111a62f1ea Update bash fzf option completion 2026-02-19 00:15:08 +09:00
Junegunn Choi 33cac3f0e7 Fix test case 2026-02-18 23:00:33 +09:00
Junegunn Choi 74e98cac5c Fix --preview-window follow not working correctly with wrapping (contd.) 2026-02-18 21:55:28 +09:00
Junegunn Choi c338df02c4 Fix --preview-window follow not working correctly with wrapping
Fix #3243
Fix #4258
2026-02-18 21:36:35 +09:00
Junegunn Choi 69e9abdab4 Implement word wrapping in the list section
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-02-18 15:20:56 +09:00
Junegunn Choi b6411beaa1 Implement word wrapping in the preview window
Example:
  fzf --preview 'bat --style=plain --color=always {}' \
      --preview-window wrap-word \
      --bind space:toggle-preview-wrap-word

Close https://github.com/junegunn/fzf/discussions/3383
2026-02-18 13:35:02 +09:00
Junegunn Choi b56d614ba2 Add underline style variants and underline color support
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Support double, curly, dotted, and dashed underline styles via --color
(e.g. underline-curly) and ANSI passthrough (SGR 4:N, 58, 59) with --ansi.

Close #4633
Close #4678

Thanks to @shtse8 for the test cases.
2026-02-15 01:06:46 +09:00
Junegunn Choi 49ab253555 Run sponsors workflow at midnight KST on Sundays 2026-02-15 01:00:42 +09:00
Junegunn Choi 0e859a18ed Update README
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-02-09 09:43:47 +09:00
Sam Killen 880dd20b18 Make symlinks to directories to return as directories, not files (#4676)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Fix #4675
2026-02-08 12:57:45 +09:00
junegunn 91aa25c863 Deploying to master from @ junegunn/fzf@7e62b34087 🚀
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-02-08 00:06:19 +00:00
LM 7e62b34087 Add fish completion support (#4605)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
2026-02-05 12:50:26 +09:00
Stephen Bolton f9f0014c16 Fix bug in advanced guide for using fzf as a ripgrep launcher (#4670)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
With the previous version if you passed in an initial search term that
resulted in 0 results you would get rg error output in the prompt. It
looks like down the change path the `|| true` clause was added but it
also needs to be on the initial query.
2026-02-04 17:14:02 +09:00
Junegunn Choi ab3b9fef52 Update README
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-02-02 09:40:38 +09:00
LangLangBart 7d9724157c fix(terminal): handle SIGHUP signal (#4668)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-02-01 19:51:49 +09:00
Junegunn Choi bc8967632b Fix preview process not killed on exit
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Thanks to @LangLangBart for the investigation and the suggested fix.

Fix #4667
2026-02-01 11:32:40 +09:00
Junegunn Choi 6360c9261c Fix coloring of items with zero-width characters
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
This commit fixes incorrect coloring for items that contain zero-width
characters. It also makes ellipsis coloring consistent when text is
trimmed from either the left or the right.

Fix #4620
Close #4646
2026-02-01 11:08:23 +09:00
Junegunn Choi e653628458 lint: test code 2026-02-01 11:08:23 +09:00
junegunn 28747e5cb6 Deploying to master from @ junegunn/fzf@9725eac314 🚀 2026-02-01 00:03:06 +00:00
Rahul Narsingipyta 9725eac314 fzf-tmux: Use mktemp, and fix TERM quoting (#4664)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
2026-01-28 00:39:05 +09:00
Junegunn Choi b389616030 Fix track-current unset after a combined movement action
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Fix #4649
Close #4663
2026-01-26 22:00:30 +09:00
junegunn 25b2248f11 Deploying to master from @ junegunn/fzf@14564e4fc7 🚀
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-01-25 00:02:24 +00:00
Junegunn Choi 14564e4fc7 Avoid clearing the rest of the current line on start
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
Fix #4652

Code was reorganized so that it doesn't delete the current line if the
cursor is not at the front. This should be acceptable in almost all
cases.

Thanks to @phanen for reporting the issue and suggesting the fix.
2026-01-22 12:23:29 +09:00
junegunn d01eaa9de3 Deploying to master from @ junegunn/fzf@aad805d0d3 🚀
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-01-18 00:02:25 +00:00
Junegunn Choi aad805d0d3 Fix x-api-key header not required for GET requests
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
Fix #4627
2026-01-17 15:13:57 +09:00
junegunn d1f037059a Deploying to master from @ junegunn/fzf@3f94bcb5bf 🚀
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-01-11 00:02:24 +00:00
Junegunn Choi 3f94bcb5bf Cancel key reading when 'execute' triggered via a server request (#4653)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
Fix #4524
Close #4648
2026-01-09 00:29:40 +09:00
junegunn 3c7cbc9d47 Deploying to master from @ junegunn/fzf@28e2a067e5 🚀
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2026-01-04 00:02:18 +00:00
James Lazo (jazo) 28e2a067e5 fix: rebind readline command redraw-current-line (#4635)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
Close #4634
2026-01-03 01:44:49 +09:00
junegunn b29e2ee2d1 Deploying to master from @ junegunn/fzf@029b241dbb 🚀
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-12-28 00:02:17 +00:00
mrclmr 029b241dbb Fix goreleaser deprecation warnings (#4644)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
Current goreleaser v2.13.1 has several deprecations:
* since v2.2: https://goreleaser.com/deprecations#snapshotname_template
* since v2.6: https://goreleaser.com/deprecations#archivesformat
* since v2.6: https://goreleaser.com/deprecations#archivesformat_overridesformat
* since v2.8: https://goreleaser.com/deprecations#archivesbuilds

Check build output locally: `goreleaser release --clean --snapshot`
2025-12-24 08:57:43 +09:00
Marcel Meyer d6ded42026 Replace nested max calls with single max
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-12-23 09:14:33 +09:00
Marcel Meyer 6eb4b41e34 Add generic utils constraint function 2025-12-23 09:14:33 +09:00
Marcel Meyer 14b5e1d88c Replace utils Min, Max with builtin min, max 2025-12-23 09:14:33 +09:00
junegunn 603240122e Deploying to master from @ junegunn/fzf@8d688521fe 🚀
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-12-21 00:02:22 +00:00
Charalambos Emmanouilidis 8d688521fe Fix --accept-nth being ignored in filter mode (#4636)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
The --accept-nth option was not being respected when using --filter mode.
This caused fzf to output entire lines instead of only the specified fields.

Added buildItemTransformer() helper function to consistently apply field
transformations across filter mode (both streaming and non-streaming) and
select1/exit0 modes.

Fixes #4615
2025-12-19 18:31:39 +09:00
Jean-Yves LENHOF 775129367a docs(README): add mise alternative method installation (#4637)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Signed-off-by: jylenhof <jygithub@lenhof.eu.org>
2025-12-18 09:59:11 +09:00
junegunn b3b221854b Deploying to master from @ junegunn/fzf@c8cf0992c1 🚀
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-12-14 00:02:25 +00:00
LangLangBart c8cf0992c1 zsh foreign test (#4622)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
* test(zsh): add test for C-r with foreign commands

* ci: make docker command configurable via variable

Allows using alternative container runtimes, e.g., DOCKER=podman make docker-test

* test(zsh): use unique histfile for foreign commands test

* test(zsh): use multi select in foreign test
2025-12-08 11:15:44 +09:00
junegunn 33d8d51c8a Deploying to master from @ junegunn/fzf@b473477c22 🚀
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-12-07 00:02:17 +00:00
Junegunn Choi b473477c22 Update README
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
2025-12-04 08:36:49 +09:00
LangLangBart fcc4178bca Updates man page documentation (#4607)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
* docs(man): Add example for {+f} in preview command
* docs(man): replace echo -e with printf to improve portability across different shells
2025-12-02 18:29:46 +09:00
LangLangBart cfc37caabc feat(zsh): Handle multi-line history selection (#4595)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-11-30 21:32:55 +09:00
Junegunn Choi af2a81dc02 Add Goods code
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
2025-11-29 14:39:43 +09:00
Junegunn Choi be5a687281 Goods 2025-11-29 13:59:43 +09:00
RT 771e35b972 feat: add alt-gutter color option (#4602)
* Add alt-gutter color option

* Simplify the code

---------

Co-authored-by: Junegunn Choi <junegunn.c@gmail.com>
2025-11-29 10:43:13 +09:00
Junegunn Choi 60a5be1e65 Do not allow very long queries in FuzzyMatchV2
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Close #4608
2025-11-28 18:41:45 +09:00
junegunn 1d5e87f5e4 Deploying to master from @ junegunn/fzf@3db63f5e52 🚀
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-11-23 00:02:24 +00:00
LangLangBart 3db63f5e52 fix(terminal): correct display width calculation with maxWidth (#4596)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
fix #4593

* test(core): add test for --freeze-right with long ellipsis
2025-11-20 09:09:36 +09:00
Junegunn Choi 2ab923f3ae 0.67.0
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-11-16 20:02:39 +09:00
Massimo Mund c3e6d9a8f9 Distinguish between Ctrl-H and Ctrl-Backspace in Windows (#4590)
Since you can actually distinguish between Ctrl-H and Ctrl-Backspace in Windows we need to reintroduce the tui.CtrlH constant. On *nix systems we map all Ctrl(-Alt)-h to Ctrl(-Alt)-Backspace internally, but you can use either in --bind.
2025-11-16 20:00:24 +09:00
Junegunn Choi 2471edf3ff Make ctrl-alt-h a synonym of ctrl-alt-backspace on non-Windows environment (#4589) 2025-11-16 16:33:53 +09:00
junegunn 53a8aeeb72 Deploying to master from @ junegunn/fzf@60b35e748b 🚀
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-11-16 00:02:13 +00:00
Junegunn Choi 60b35e748b Header and footer should not be wider than the list
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
Example:
  WIDE=$(printf 'x%.0s' {1..1000})
  (echo $WIDE; echo $WIDE) |
    fzf --header-lines 1 --style full --ellipsis XX --header "$WIDE" \
        --no-header-lines-border --footer "$WIDE" --no-footer-border
2025-11-15 11:41:51 +09:00
Junegunn Choi 3f499f055e Avoid truncating ellipsis to avoid confusion
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-11-13 23:00:32 +09:00
Junegunn Choi 1df99db0b2 Keep the previous delimiter before frozen columns 2025-11-13 22:38:49 +09:00
dependabot[bot] 535b610a6b Bump github/codeql-action from 3 to 4 (#4554)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: '4'
  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>
2025-11-13 10:48:43 +09:00
phanium 91fab3b3c2 Fix lint warnings (#4586)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
go run golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@latest -fix -test ./...
2025-11-12 22:05:17 +09:00
Junegunn Choi b9f2bf64ff Add --freeze-right=N option to keep the rightmost N fields visible 2025-11-12 22:00:27 +09:00
Junegunn Choi 07d53cb7e4 Add --freeze-left=N option to keep the leftmost N fields visible 2025-11-12 22:00:27 +09:00
Massimo Mund ead534a1be Fix modifier detection for Backspace / Ctrl-H on Windows (#4582)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Windows sends different key events and modifier combinations to theFullscreenRenderer than a tcell FullscreenRenderer on Linux (-tags tcell).
This led to Ctrl+H being misinterpreted (and therefore unbindable) on some Windows builds.

Basically reverts changes to `src/tui/tcell.go` introduced by `a0cabe0`.
2025-11-10 19:12:01 +09:00
Junegunn Choi 8a05083503 Fix reading an extra key after a terminal action
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Fix #4578
2025-11-09 15:36:07 +09:00
phanium e659b46ff5 feat: append spinner in the end when --info=inline (#4567)
Test:
  go run main.go --query "$(seq 100)" --info inline --border < <(sleep 60)
  go run main.go --query "$(seq 100)" --info inline --info-command 'echo hello' --border < <(sleep 60)

Close #4344
Close #619

Co-authored-by: Junegunn Choi <junegunn.c@gmail.com>
2025-11-09 10:44:27 +09:00
Junegunn Choi 991c36453c [man] Add --gutter-raw
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
Close #4579
2025-11-08 17:50:25 +09:00
junegunn 4d563c6dfa Deploying to master from @ junegunn/fzf@5cb695744f 🚀
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-11-02 00:02:16 +00:00
Koichi Murase 5cb695744f [bash,zsh] Fix the version check for mawk (#4574)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
We have been checking the mawk version by extracting <x>, <y>, <z>,
and <d> part from "mawk <x>.<y>.<z> <d>" in the output of the "mawk -W
version" and testing <x>, <y>, <z>, and <d> using an arithmetic
evalaution.  However, <d> is ensured to be an integer only in "x.y.z
>= 1.3.4".  Otherwise, it may cause a syntax error in the arithmetic
evaluation.  The mawk started to include the date as an integer in the
<d> position only from mawk-1.3.3-20090721.  We should first check
that "x.y.z >= 1.3.4" and then check the value of "d".  In case, "mawk
-W version" produces a completely different text, we should also
redirect stderr of the arithmetic commands to /dev/null.
2025-10-31 21:14:41 +09:00
Junegunn Choi c1b259c042 0.66.1
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-10-26 15:11:51 +09:00
junegunn 1a0371e2c7 Deploying to master from @ junegunn/fzf@aa259fdc19 🚀
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-10-26 00:02:16 +00:00
Junegunn Choi aa259fdc19 Fix regression in --no-color / NO_COLOR theme
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
Fix #4561
2025-10-21 19:49:43 +09:00
junegunn b852dc8a56 Deploying to master from @ junegunn/fzf@a0cabe021d 🚀
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-10-19 00:02:19 +00:00
Junegunn Choi a0cabe021d Fix bug preventing 'ctrl-h' from being bound to an action
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
Fix #4556
2025-10-15 12:16:09 +09:00
Junegunn Choi 8cdfb23df6 0.66.0
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-10-12 22:17:52 +09:00
Junegunn Choi 4ffde48e2f Fix --bold inheritance
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Fix #4548
2025-10-12 13:58:46 +09:00
Junegunn Choi f2b33f038a Revert "Make query string in --disabled state bold as before"
This reverts commit ab407c4645.
2025-10-12 13:58:46 +09:00
junegunn d5913bf86e Deploying to master from @ junegunn/fzf@0e9026b817 🚀
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-10-12 00:02:08 +00:00
Jacobo de Vera 0e9026b817 feat: Allow disabling Ctrl-R binding in shell integration (#4535)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
Close #4417
2025-10-12 01:57:31 +09:00
Junegunn Choi ab407c4645 Make query string in --disabled state bold as before
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Fix #4546
2025-10-11 09:35:48 +09:00
Junegunn Choi 91c4bef35f Update CHANGELOG
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-10-10 03:41:37 +09:00
Junegunn Choi bf77206221 Improve Unix domain socket handling
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
- Check if the file is in use
- Change the permission to 0600
2025-10-09 13:52:10 +09:00
Junegunn Choi 0cb1be3f04 Fix --help output: socket path cannot be omitted
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-10-09 01:12:30 +09:00
Junegunn Choi 01cb38a5fb Add Unix domain socket support for --listen
Close #4541
2025-10-09 01:07:59 +09:00
Junegunn Choi c38c6cad79 Update CHANGELOG 2025-10-09 00:17:00 +09:00
Junegunn Choi ba6fc40cfd Add 'best' to man page 2025-10-09 00:17:00 +09:00
Junegunn Choi dd46a256c0 Fix offset-up and offset-down with --layout=reverse-list
Related: 3df06a1c68
2025-10-09 00:17:00 +09:00
Junegunn Choi d19ce0ad8d Add 'best' action 2025-10-09 00:17:00 +09:00
Junegunn Choi ed7becfb47 Go to the closest match when disabling raw mode 2025-10-09 00:17:00 +09:00
Junegunn Choi 9ace1351ff ADD $FZF_DIRECTION 2025-10-09 00:17:00 +09:00
Junegunn Choi e1de29bc40 CTRL-R: Bind ALT-R to toggle-raw 2025-10-09 00:17:00 +09:00
Junegunn Choi 0df7d10550 Rename: '--color hidden' to '--color nomatch' 2025-10-09 00:17:00 +09:00
Junegunn Choi 91e119a77e Fix non-matching items not refreshing after clearing query 2025-10-09 00:17:00 +09:00
Junegunn Choi 3984161f6c Fix: 'hidden' style not applied to text without colors 2025-10-09 00:17:00 +09:00
Junegunn Choi 91beacf0f4 Add special 'strip' style attribute for stripping colors
Test cases:
  fd --color always | fzf --ansi --delimiter /
  fd --color always | fzf --ansi --delimiter / --nth -1 --color fg:dim,nth:regular
  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 --raw
  fd --color always | fzf --ansi --delimiter / --nth -1 --color fg:dim:strip,nth:regular,hidden:strikethrough --raw
  fd --color always | fzf --ansi --delimiter / --nth -1 --color fg:dim:strip,nth:regular,hidden:strip:strikethrough --raw
  fd --color always | fzf --ansi --delimiter / --nth -1 --color fg:dim:strip,nth:regular,hidden:strip:dim:strikethrough --raw
2025-10-09 00:17:00 +09:00
Junegunn Choi e6ad01fb90 Revise color configuration 2025-10-09 00:17:00 +09:00
Junegunn Choi ce2200e908 Do not allow gutter characters with width other than 1 2025-10-09 00:17:00 +09:00
Junegunn Choi 548061dbde --gutter ' ' --color gutter:reverse 2025-10-09 00:17:00 +09:00
Junegunn Choi 8f0c91545d Add $FZF_RAW for conditional actions 2025-10-09 00:17:00 +09:00
Junegunn Choi 0eefcf348e Update CHANGELOG 2025-10-09 00:17:00 +09:00
Junegunn Choi c1f8d18a0c Add enable-raw and disable-raw actions 2025-10-09 00:17:00 +09:00
Junegunn Choi 8585969d6d Refactor action implementation 2025-10-09 00:17:00 +09:00
Junegunn Choi 8a943a9b1a Remove TODO comments 2025-10-09 00:17:00 +09:00
Junegunn Choi c87a8eccd4 Add '--bind ctrl-x:toggle-raw' to CTRL-R bindings 2025-10-09 00:17:00 +09:00
Junegunn Choi 65df0abf0e Introduce 'raw' mode 2025-10-09 00:17:00 +09:00
junegunn b51bc6b50e Deploying to master from @ junegunn/fzf@febaadbee5 🚀
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-10-05 00:02:16 +00:00
Junegunn Choi febaadbee5 Fix stray character artifacts when scrollbar is hidden
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
Fix #4537
2025-10-04 21:56:56 +09:00
dependabot[bot] 0e67c5aa7a Bump actions/setup-go from 5 to 6 (#4513)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5 to 6.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '6'
  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>
2025-09-29 21:20:26 +09:00
mickychang9 760d1b7c58 refactor: use maps.Copy and maps.Clone (#4518)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Signed-off-by: mickychang9 <mickychang9@outlook.com>
2025-09-29 18:11:19 +09:00
Junegunn Choi 9bdacc8df2 Update --help output to avoid confusion
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-09-28 23:56:51 +09:00
junegunn 8e936ecfa7 Deploying to master from @ junegunn/fzf@db2e95b1f2 🚀
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-09-28 00:02:15 +00:00
Junegunn Choi db2e95b1f2 Remove unused field
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
2025-09-27 22:34:12 +09:00
alex-huff 687074e772 merger: fix chunk cache never getting cleared (#4531)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Commit 7fc13c5 indroduced less aggressive cache invalidation for the
chunk cache but saved the new revision before comparing it with the old
one, and so the cache was never considered invalid.

Fixes #4529
2025-09-27 09:01:13 +09:00
Junegunn Choi 3401c2e0c7 Remove alignment in .tool-versions for RuboCop
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-09-24 22:41:54 +09:00
Junegunn Choi e8cb315419 Apply shfmt to bash script files (make fmt) 2025-09-24 22:41:54 +09:00
Junegunn Choi f0c4ee4047 make lint: Perform bash script linting 2025-09-24 22:41:54 +09:00
xieyonn de0df2422a feat: add make fmt for *.sh *.bash
1. add .editorconfig file, add rules for .sh .bash files.
2. add make fmt target, use:
    - gofmt *.go.
    - shfmt *.sh *.bash, shell/completion.bash, shell/key-bindings.bash
        need a left indent due to an outermost if block.
3. add shfmt check for bash scripts in make lint target.
4. install shfmt in actions.
2025-09-24 22:41:54 +09:00
Massimo Mund 148b0a94cd tui/light: consume full 7-byte CSI sequences to prevent leftover printing (#4528)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
- Fix parsing in escSequence so 7-byte CSI forms (e.g. ESC [ 5 ; 10 ~) set *sz = 7 and the entire sequence is consumed.
- Prevents trailing bytes (like 10~) from remaining in the input buffer and being printed as stray characters.
2025-09-23 23:33:41 +09:00
Junegunn Choi ca294109c3 Apply RuboCop suggestions
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-09-22 21:23:54 +09:00
Junegunn Choi 9cad2686e9 Update .tool-versions
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-09-21 21:19:49 +09:00
junegunn 9a45172232 Deploying to master from @ junegunn/fzf@2a92c7d792 🚀
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-09-21 00:02:16 +00:00
Junegunn Choi 2a92c7d792 Adjust base16 (16) theme (#4501)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been cancelled
Motivation:

`--color base16` can be a better default than `dark` or `light`, since it uses
the colors defined by the current theme. This usually blends in more
naturally and works well in both light and dark modes.

However, some elements were previously hard-coded with white or black
foreground colors, which can cause rendering issues in certain terminal
themes.
2025-09-17 19:38:49 +09:00
Junegunn Choi f5975cf870 Add --gutter to --help and man page
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
2025-09-16 21:30:01 +09:00
105 changed files with 9724 additions and 2746 deletions
+20
View File
@@ -0,0 +1,20 @@
root = true
[*.{sh,bash,fish}]
indent_style = space
indent_size = 2
simplify = true
binary_next_line = false
switch_case_indent = true
space_redirects = true
function_next_line = false
# also bash scripts.
[{install,uninstall,bin/fzf-preview.sh,bin/fzf-tmux}]
indent_style = space
indent_size = 2
simplify = true
binary_next_line = false
switch_case_indent = true
space_redirects = true
function_next_line = false
+1
View File
@@ -0,0 +1 @@
* @junegunn
+17
View File
@@ -0,0 +1,17 @@
## Contribution Policy
We do not accept pull requests generated primarily by AI without genuine understanding or real-world usage context.
All contributions are expected to demonstrate:
- A clear understanding of the codebase
- Alignment with product direction
- Thoughtful reasoning behind changes
- Evidence of real-world usage or hands-on experience with the problem
If these expectations are not met, we would prefer to implement the changes ourselves rather than spend time reviewing low-effort submissions.
---
## Acknowledgement
- [ ] I confirm that this PR meets the above expectations and reflects my own understanding and real-world context.
+64
View File
@@ -0,0 +1,64 @@
go:
- changed-files:
- any-glob-to-any-file:
- src/**
- main.go
- go.mod
- go.sum
shell:
- changed-files:
- any-glob-to-any-file:
- shell/**
bash:
- changed-files:
- any-glob-to-any-file:
- shell/**/*.bash
zsh:
- changed-files:
- any-glob-to-any-file:
- shell/**/*.zsh
fish:
- changed-files:
- any-glob-to-any-file:
- shell/**/*.fish
vim:
- changed-files:
- any-glob-to-any-file:
- plugin/**
docs:
- changed-files:
- any-glob-to-any-file:
- '*.md'
- doc/**
- man/**
ci:
- changed-files:
- any-glob-to-any-file:
- .github/**
build:
- changed-files:
- any-glob-to-any-file:
- Makefile
- .goreleaser.yml
- Dockerfile
test:
- changed-files:
- any-glob-to-any-file:
- test/**
- src/**/*_test.go
install:
- changed-files:
- any-glob-to-any-file:
- install
- install.ps1
- uninstall
+3 -3
View File
@@ -33,12 +33,12 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4
+17
View File
@@ -0,0 +1,17 @@
name: Label PRs
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v6
with:
configuration-path: .github/labeler.yml
+10 -5
View File
@@ -5,7 +5,7 @@ on:
push:
branches: [ master, devel ]
pull_request:
branches: [ master ]
branches: [ master, devel ]
workflow_dispatch:
permissions:
@@ -23,17 +23,17 @@ jobs:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: "1.23"
- name: Setup Ruby
uses: ruby/setup-ruby@v1
uses: ruby/setup-ruby@7372622e62b60b3cb750dcd2b9e32c247ffec26a # v1
with:
ruby-version: 3.4.1
ruby-version: 3.4.6
- name: Install packages
run: sudo apt-get install --yes zsh fish tmux
run: sudo apt-get install --yes zsh fish tmux shfmt
- name: Install Ruby gems
run: bundle install
@@ -44,5 +44,10 @@ jobs:
- name: Unit test
run: make test
- name: Fuzz test
run: |
go test ./src/algo/ -fuzz=FuzzIndexByteTwo -fuzztime=5s
go test ./src/algo/ -fuzz=FuzzLastIndexByteTwo -fuzztime=5s
- name: Integration test
run: make install && ./install --all && tmux new-session -d && ruby test/runner.rb --verbose
+3 -3
View File
@@ -20,17 +20,17 @@ jobs:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: "1.23"
- name: Setup Ruby
uses: ruby/setup-ruby@v1
uses: ruby/setup-ruby@7372622e62b60b3cb750dcd2b9e32c247ffec26a # v1
with:
ruby-version: 3.0.0
- name: Install packages
run: HOMEBREW_NO_INSTALL_CLEANUP=1 brew install fish zsh tmux
run: HOMEBREW_NO_INSTALL_CLEANUP=1 brew install fish zsh tmux shfmt
- name: Install Ruby gems
run: gem install --no-document minitest:5.14.2 rubocop:1.0.0 rubocop-minitest:0.10.1 rubocop-performance:1.8.1
-24
View File
@@ -1,24 +0,0 @@
---
name: Generate Sponsors README
on:
workflow_dispatch:
schedule:
- cron: 0 0 * * 0
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎️
uses: actions/checkout@v5
- name: Generate Sponsors 💖
uses: JamesIves/github-sponsors-readme-action@v1
with:
token: ${{ secrets.SPONSORS_TOKEN }}
file: 'README.md'
- name: Deploy to GitHub Pages 🚀
uses: JamesIves/github-pages-deploy-action@v4
with:
branch: master
folder: '.'
+1 -1
View File
@@ -7,4 +7,4 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: crate-ci/typos@v1.29.4
- uses: crate-ci/typos@685eb3d55be2f85191e8c84acb9f44d7756f84ab # v1.29.4
+1 -1
View File
@@ -7,7 +7,7 @@ jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: vedantmgoyal2009/winget-releaser@v2
- uses: vedantmgoyal2009/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2
with:
identifier: junegunn.fzf
installers-regex: '-windows_(armv7|arm64|amd64)\.zip$'
+9 -4
View File
@@ -22,6 +22,7 @@ builds:
- loong64
- ppc64le
- s390x
- riscv64
goarm:
- "5"
- "6"
@@ -39,6 +40,8 @@ builds:
goarch: arm64
- goos: openbsd
goarch: arm64
- goos: openbsd
goarch: riscv64
- goos: android
goarch: amd64
- goos: android
@@ -82,12 +85,14 @@ notarize:
archives:
- name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
builds:
ids:
- fzf
format: tar.gz
formats:
- tar.gz
format_overrides:
- goos: windows
format: zip
formats:
- zip
files:
- non-existent*
@@ -99,7 +104,7 @@ release:
name_template: '{{ .Version }}'
snapshot:
name_template: "{{ .Version }}-devel"
version_template: "{{ .Version }}-devel"
changelog:
sort: asc
+3 -2
View File
@@ -1,2 +1,3 @@
golang 1.23.12
ruby 3.4.1
golang 1.23
ruby 3.4
shfmt 3.12
+8 -8
View File
@@ -309,16 +309,16 @@ I know it's a lot to digest, let's try to break down the code.
available color options.
- The value of `--preview-window` option consists of 5 components delimited
by `,`
1. `up` Position of the preview window
1. `60%` Size of the preview window
1. `border-bottom` Preview window border only on the bottom side
1. `+{2}+3/3` Scroll offset of the preview contents
1. `~3` Fixed header
1. `up` -- Position of the preview window
1. `60%` -- Size of the preview window
1. `border-bottom` -- Preview window border only on the bottom side
1. `+{2}+3/3` -- Scroll offset of the preview contents
1. `~3` -- Fixed header
- Let's break down the latter two. We want to display the bat output in the
preview window with a certain scroll offset so that the matching line is
positioned near the center of the preview window.
- `+{2}` The base offset is extracted from the second token
- `+3` We add 3 lines to the base offset to compensate for the header
- `+{2}` -- The base offset is extracted from the second token
- `+3` -- We add 3 lines to the base offset to compensate for the header
part of `bat` output
- ```
───────┬──────────────────────────────────────────────────────────
@@ -363,7 +363,7 @@ projects, and it will free up memory as you narrow down the results.
RG_PREFIX="rg --column --line-number --no-heading --color=always --smart-case "
INITIAL_QUERY="${*:-}"
fzf --ansi --disabled --query "$INITIAL_QUERY" \
--bind "start:reload:$RG_PREFIX {q}" \
--bind "start:reload:$RG_PREFIX {q} || true" \
--bind "change:reload:sleep 0.1; $RG_PREFIX {q} || true" \
--delimiter : \
--preview 'bat --color=always {1} --highlight-line {2}' \
+475 -19
View File
@@ -1,32 +1,488 @@
CHANGELOG
=========
0.72.0
------
- `--header-border`, `--header-lines-border`, and `--footer-border` now accept a new `inline` style that embeds the section inside the list frame, separated from the list content by a horizontal line. When the list border has side segments, the separator joins them as T-junctions.
- Requires a `--list-border` shape that has both top and bottom segments (`rounded`, `sharp`, `bold`, `double`, `block`, `thinblock`, or `horizontal`); falls back to `line` otherwise. `horizontal` has no side borders, so the separator is drawn without T-junction endpoints.
- Sections stack. Example combining all three:
```sh
ps -ef | fzf --reverse --style full:double \
--header 'Select a process' --header-lines 1 \
--bind 'load:transform-footer:echo $FZF_TOTAL_COUNT processes' \
--header-border=inline --header-lines-border=inline \
--footer-border=inline
```
- `--header-label` and `--footer-label` render on their respective separator row.
- The separator inherits `--color list-border` when the section's own border color is not explicitly set.
- `inline` takes precedence over `--header-first`: the inline section stays inside the list frame. `--header-border=inline` requires `--header-lines-border` to be `inline` or unset.
- [vim] Move and resize popup window when detecting `VimResized` event (#4778) (@Vulcalien)
- Bug fixes
- Fixed gutter display in `--style=minimal`
- Fixed arrow keys / Home / End without modifiers being ignored under the kitty keyboard protocol (#4776) (@TymekDev)
- bash: Persist history deletion when `histappend` is on (#4764)
0.71.0
------
_Release highlights: https://junegunn.github.io/fzf/releases/0.71.0/_
- Added `--popup` as a new name for `--tmux` with Zellij support
- `--popup` starts fzf in a tmux popup or a Zellij floating pane
- `--tmux` is now an alias for `--popup`
- Requires tmux 3.3+ or Zellij 0.44+
- Cross-reload item identity with `--id-nth`
- Added `--id-nth=NTH` to define item identity fields for cross-reload operations
- When a `reload` is triggered with tracking enabled, fzf searches for the tracked item by its identity fields in the new list.
- `--track --id-nth ..` tracks by the entire line
- `--track --id-nth 1` tracks by the first field
- `--track` without `--id-nth` retains the existing index-based tracking behavior
- The UI is temporarily blocked (prompt dimmed, input disabled) until the item is found or loading completes.
- Press `Escape` or `Ctrl-C` to cancel the blocked state without quitting
- Info line shows `+T*` / `+t*` while searching
- With `--multi`, selected items are preserved across `reload-sync` by matching their identity fields
- Performance improvements
- The search performance now scales linearly with the number of CPU cores, as we dropped static partitioning to allow better load balancing across threads.
```
=== query: 'linux' ===
[all] baseline: 21.95ms current: 17.47ms (1.26x) matches: 179966 (12.79%)
[1T] baseline: 179.63ms current: 180.53ms (1.00x) matches: 179966 (12.79%)
[2T] baseline: 97.38ms current: 90.05ms (1.08x) matches: 179966 (12.79%)
[4T] baseline: 53.83ms current: 44.77ms (1.20x) matches: 179966 (12.79%)
[8T] baseline: 41.66ms current: 22.58ms (1.84x) matches: 179966 (12.79%)
```
- Improved the cache structure, reducing memory footprint per entry by 86x.
- With the reduced per-entry cost, the cache now has broader coverage.
- Shell integration improvements
- bash: CTRL-R now supports multi-select and `shift-delete` to delete history entries (#4715)
- fish:
- Improved command history (CTRL-R) (#4703) (@bitraid)
- Rewrite completion script (SHIFT-TAB) (#4731) (@bitraid)
- Increase minimum fish version requirement to 3.4.0 (#4731) (@bitraid)
- `GET /` HTTP endpoint now includes `positions` field in each match entry, providing the indices of matched characters for external highlighting (#4726)
- Allow adaptive height with negative value (`--height=~-HEIGHT`) (#4682)
- Bug fixes
- `--walker=follow` no longer follows symlinks whose target is an ancestor of the walker root, avoiding severe resource exhaustion when a symlink points outside the tree (e.g. Wine's `z:` → `/`) (#4710)
- Fixed AWK tokenizer not treating a new line character as whitespace
- Fixed `--{accept,with}-nth` removing trailing whitespaces with a non-default `--delimiter`
- Fixed OSC8 hyperlinks being mangled when the URL contains unicode characters (#4707)
- Fixed `--with-shell` not handling quoted arguments correctly (#4709)
- Fixed child processes not being terminated on Windows (#4723) (@pjeby)
- Fixed preview scrollbar not rendered after `toggle-preview`
- Fixed preview follow/scroll with long wrapped lines
- Fixed tab width when `--frozen-left` is used
- Fixed preview mouse events being processed when no preview window exists
- zsh: Fixed history widget when `sh_glob` option is on (#4714) (@EvanHahn)
0.70.0
------
- Added `change-with-nth` action for dynamically changing the `--with-nth` option.
- Requires `--with-nth` to be set initially.
- Multiple options separated by `|` can be given to cycle through.
```sh
echo -e "a b c\nd e f\ng h i" | fzf --with-nth .. \
--bind 'space:change-with-nth(1|2|3|1,3|2,3|)'
```
- Added `change-header-lines` action for dynamically changing the `--header-lines` option
- Performance improvements (1.3x to 1.9x faster filtering depending on query)
```
=== query: 'l' ===
[all] baseline: 168.87ms current: 95.21ms (1.77x) matches: 5069891 (94.78%)
[1T] baseline: 1652.22ms current: 841.40ms (1.96x) matches: 5069891 (94.78%)
=== query: 'lin' ===
[all] baseline: 343.27ms current: 252.59ms (1.36x) matches: 3516507 (65.74%)
[1T] baseline: 3199.89ms current: 2230.64ms (1.43x) matches: 3516507 (65.74%)
=== query: 'linux' ===
[all] baseline: 85.47ms current: 63.72ms (1.34x) matches: 307229 (5.74%)
[1T] baseline: 774.64ms current: 589.32ms (1.31x) matches: 307229 (5.74%)
=== query: 'linuxlinux' ===
[all] baseline: 55.13ms current: 35.67ms (1.55x) matches: 12230 (0.23%)
[1T] baseline: 461.99ms current: 332.38ms (1.39x) matches: 12230 (0.23%)
=== query: 'linuxlinuxlinux' ===
[all] baseline: 51.77ms current: 32.53ms (1.59x) matches: 865 (0.02%)
[1T] baseline: 409.99ms current: 296.33ms (1.38x) matches: 865 (0.02%)
```
- Fixed `nth` attribute merge order to respect precedence hierarchy (#4697)
- bash: Replaced `printf` with builtin `printf` to bypass local indirections (#4684) (@DarrenBishop)
0.68.0
------
- Implemented word wrapping in the list section
- Added `--wrap=word` (or `--wrap-word`) option and `toggle-wrap-word` action for word-level line wrapping in the list section
- Changed default binding of `ctrl-/` and `alt-/` from `toggle-wrap` to `toggle-wrap-word`
```sh
fzf --wrap=word
```
- Implemented word wrapping in the preview window
- Added `wrap-word` flag for `--preview-window` to enable word-level wrapping
- Added `toggle-preview-wrap-word` action
```sh
fzf --preview 'bat --style=plain --color=always {}' \
--preview-window wrap-word \
--bind space:toggle-preview-wrap-word
```
- Added support for underline style variants in `--color`: `underline-double`, `underline-curly`, `underline-dotted`, `underline-dashed`
```sh
fzf --color 'fg:underline-curly,current-fg:underline-dashed'
```
- Added support for underline styles (`4:N`) and underline colors (SGR 58/59)
```sh
# In the list section
printf '\e[4:3;58;2;255;0;0mRed curly underline\e[0m\n' | fzf --ansi
# In the preview window
fzf --preview "printf '\e[4:3;58;2;255;0;0mRed curly underline\e[0m\n'"
```
- Added `--preview-wrap-sign` to set a different wrap indicator for the preview window
- Added `alt-gutter` color option (#4602) (@hedgieinsocks)
- Added `$FZF_WRAP` environment variable to child processes (`char` or `word` when wrapping is enabled) (#4672) (@bitraid)
- fish: Improved command history (CTRL-R) (#4672) (@bitraid)
- Enabled syntax highlighting in the list on fish 4.3.3+
- Added syntax-highlighted preview window that auto-shows for long or multi-line commands
- Added `ALT-ENTER` to reformat and insert selected commands
- Improved handling of bulk deletion of selected history entries (`SHIFT-DELETE`)
- Added fish completion support (#4605) (@lalvarezt)
- zsh: Handle multi-line history selection (#4595) (@LangLangBart)
- Bug fixes
- Fixed `_fzf_compgen_{path,dir}` to respect `FZF_COMPLETION_{PATH,DIR}_OPTS` (#4592) (@shtse8, @LangLangBart)
- Fixed `--preview-window follow` not working correctly with wrapping (#3243, #4258)
- Fixed symlinks to directories being returned as files (#4676) (@skk64)
- Fixed SIGHUP signal handling (#4668) (@LangLangBart)
- Fixed preview process not killed on exit (#4667)
- Fixed coloring of items with zero-width characters (#4620)
- Fixed `track-current` unset after a combined movement action (#4649)
- Fixed `--accept-nth` being ignored in filter mode (#4636) (@charemma)
- Fixed display width calculation with `maxWidth` (#4596) (@LangLangBart)
- Fixed clearing of the rest of the current line on start (#4652)
- Fixed `x-api-key` header not required for GET requests (#4627)
- Fixed key reading not cancelled when `execute` triggered via a server request (#4653)
- Fixed rebind of readline command `redraw-current-line` (#4635) (@jameslazo)
- Fixed `fzf-tmux` `TERM` quoting and added `mktemp` usage (#4664) (@Goofygiraffe06)
- Do not allow very long queries in `FuzzyMatchV2` (#4608)
0.67.0
------
- Added `--freeze-left=N` option to keep the leftmost N columns always visible.
```sh
# Keep the file name column fixed and always visible
git grep --line-number --color=always -- '' |
fzf --ansi --delimiter : --freeze-left 1
# Can be used with --keep-right
git grep --line-number --color=always -- '' |
fzf --ansi --delimiter : --freeze-left 1 --keep-right
```
- Also added `--freeze-right=N` option to keep the rightmost N columns always visible.
```sh
# Stronger version of --keep-right that always keeps the right-end visible
fd | fzf --freeze-right 1
# Keep the base name always visible
fd | fzf --freeze-right 1 --delimiter /
# Keep both leftmost and rightmost components visible
fd | fzf --freeze-left 1 --freeze-right 1 --delimiter /
```
- Updated `--info=inline` to print the spinner (load indicator).
- Bug fixes
0.66.1
------
- Bug fixes
- Fixed a bug preventing 'ctrl-h' from being bound to an action (#4556)
- Fixed `--no-color` / `NO_COLOR` theme (#4561)
0.66.0
------
- Style changes
- Updated `--color base16` (alias: `16`) theme so that it works better with both dark and light themes.
- Narrowed the gutter column by using the left-half block character (`▌`).
- Removed background colors from markers.
- Added `--gutter CHAR` option for customizing the gutter column. Some examples using [box-drawing characters](https://en.wikipedia.org/wiki/Box-drawing_characters):
```sh
# Right-aligned gutter
fzf --gutter '▐'
# Even thinner gutter
fzf --gutter '▎'
### Quick summary
# Checker
fzf --gutter '▚'
This version introduces many new features centered around the new "raw" mode.
# Dotted
fzf --gutter '▖'
| Type | Class | Name | Description |
| :-- | :-- | :-- | :-- |
| New | Option | `--raw` | Enable raw mode by default |
| New | Option | `--gutter CHAR` | Set the gutter column character |
| New | Option | `--gutter-raw CHAR` | Set the gutter column character in raw mode |
| Enhancement | Option | `--listen SOCKET` | Added support for Unix domain sockets |
| New | Action | `toggle-raw` | Toggle raw mode |
| New | Action | `enable-raw` | Enable raw mode |
| New | Action | `disable-raw` | Disable raw mode |
| New | Action | `up-match` | Move up to the matching item |
| New | Action | `down-match` | Move down to the matching item |
| New | Action | `best` | Move to the matching item with the best score |
| New | Color | `nomatch` | Color for non-matching items in raw mode |
| New | Env Var | `FZF_RAW` | Matching status in raw mode (0, 1, or undefined) |
| New | Env Var | `FZF_DIRECTION` | `up` or `down` depending on the layout |
| New | Env Var | `FZF_SOCK` | Path to the Unix domain socket fzf is listening on |
| Enhancement | Key | `CTRL-N` | `down` -> `down-match` |
| Enhancement | Key | `CTRL-P` | `up` -> `up-match` |
| Enhancement | Shell | `CTRL-R` binding | Toggle raw mode with `ALT-R` |
| Enhancement | Shell | `CTRL-R` binding | Opt-out with an empty `FZF_CTRL_R_COMMAND` |
# Full-width
fzf --gutter '█'
### 1. Introducing "raw" mode
# No gutter
fzf --gutter ' '
```
![](https://github.com/user-attachments/assets/9640ae11-b5f7-43fb-95f1-c29307fc17c2)
This version introduces a new "raw" mode (named so because it shows the list
"unfiltered"). In raw mode, non-matching items stay in their original positions,
but appear dimmed. This allows you to see the surrounding items of a match and
better understand the context of it. You can enable raw mode by default with
`--raw`, but it's often more useful when toggled dynamically with the
`toggle-raw` action.
```sh
tree | fzf --reverse --bind alt-r:toggle-raw
```
While non-matching items are displayed in a dimmed color, they are treated just
like matching items, so you place the cursor on them and perform any action. If
you prefer to navigate only through matching items, use the `down-match` and
`up-match` actions, which are from now on bound to `CTRL-N` and `CTRL-P`
respectively, and also to `ALT-DOWN` and `ALT-UP`.
| Key | Action | With `--history` |
| :-- | :-- | :-- |
| `down` | `down` | |
| `up` | `up` | |
| `ctrl-j` | `down` | |
| `ctrl-k` | `up` | |
| `ctrl-n` | `down-match` | `next-history` |
| `ctrl-p` | `up-match` | `prev-history` |
| `alt-down` | `down-match` | |
| `alt-up` | `up-match` | |
> [!NOTE]
> `CTRL-N` and `CTRL-P` are bound to `next-history` and `prev-history` when
> `--history` option is enabled, so in that case, you'll need to manually bind
> them, or use `ALT-DOWN` and `ALT-UP` instead.
> [!TIP]
> `up-match` and `down-match` are equivalent to `up` and `down` when not in
> raw mode, so you can safely bind them to `up` and `arrow` keys if you prefer.
> ```sh
> fzf --bind up:up-match,down:down-match
> ```
#### Customizing the behavior
In raw mode, the input list is presented in its original order, unfiltered, and
your cursor will not move to the matching item automatically. Here are ways to
customize the behavior.
```sh
# When the result list is updated, move the cursor to the item with the best score
# (assuming sorting is not disabled)
fzf --raw --bind result:best
# Move to the first matching item in the original list
# - $FZF_RAW is set to 0 when raw mode is enabled and the current item is a non-match
# - $FZF_DIRECTION is set to either 'up' or 'down' depending on the layout direction
fzf --raw --bind 'result:first+transform:[[ $FZF_RAW = 0 ]] && echo $FZF_DIRECTION-match'
```
#### Customizing the look
##### Gutter
To make the mode visually distinct, the gutter column is rendered in a dashed
line using `` character. But you can customize it with the `--gutter-raw CHAR`
option.
```sh
# Use a thinner gutter instead of the default dashed line
fzf --bind alt-r:toggle-raw --gutter-raw ▎
```
##### Color and style of non-matching items
Non-matching items are displayed in a dimmed color by default, but you can
change it with the `--color nomatch:...` option.
```sh
fzf --raw --color nomatch:red
fzf --raw --color nomatch:red:dim
fzf --raw --color nomatch:red:dim:strikethrough
fzf --raw --color nomatch:red:dim:strikethrough:italic
```
For colored input, dimming alone may not be enough, and you may prefer to remove
colors entirely. For that case, a new special style attribute `strip` has been
added.
```sh
fd --color always | fzf --ansi --raw --color nomatch:dim:strip:strikethrough
```
#### Conditional actions for raw mode
You may want to perform different actions depending on whether the current item
is a match or not. For that, fzf now exports `$FZF_RAW` environment variable.
It's:
- Undefined if raw mode is disabled
- `1` if the current item is a match
- `0` otherwise
```sh
# Do not allow selecting non-matching items
fzf --raw --bind 'enter:transform:[[ ${FZF_RAW-1} = 1 ]] && echo accept || echo bell'
```
#### Leveraging raw mode in shell integration
The `CTRL-R` binding (command history) now lets you toggle raw mode with `ALT-R`.
### 2. Style changes
The screenshot on the right shows the updated gutter style:
![](https://github.com/user-attachments/assets/8ea7b5ef-c99e-4686-905b-22eb078b700a)
This version includes a few minor updates to fzf's classic visual style:
- The gutter column is now narrower, rendered with the left-half block character (``).
- Markers no longer use background colors.
- The `--color base16` theme (alias: `16`) has been updated for better compatibility with both dark and light themes.
### 3. `--listen` now supports Unix domain sockets
If an argument to `--listen` ends with `.sock`, fzf will listen on a Unix
domain socket at the specified path.
```sh
fzf --listen /tmp/fzf.sock --no-tmux
# GET
curl --unix-socket /tmp/fzf.sock http
# POST
curl --unix-socket /tmp/fzf.sock http -d up
```
Note that any existing file at the given path will be removed before creating
the socket, so avoid using an important file path.
### 4. Added options
#### `--gutter CHAR`
The gutter column can now be customized using `--gutter CHAR` and styled with
`--color gutter:...`. Examples:
```sh
# Right-aligned gutter
fzf --gutter '▐'
# Even thinner gutter
fzf --gutter '▎'
# Yellow checker pattern
fzf --gutter '▚' --color gutter:yellow
# Classic style
fzf --gutter ' ' --color gutter:reverse
```
#### `--gutter-raw CHAR`
As noted above, the `--gutter-raw CHAR` option was also added for customizing the gutter column in raw mode.
### 5. Added actions
The following actions were introduced to support working with raw mode:
| Action | Description |
| :-- | :-- |
| `toggle-raw` | Toggle raw mode |
| `enable-raw` | Enable raw mode |
| `disable-raw` | Disable raw mode |
| `up-match` | Move up to the matching item; identical to `up` if raw mode is disabled |
| `down-match` | Move down to the matching item; identical to `down` if raw mode is disabled |
| `best` | Move to the matching item with the best score; identical to `first` if raw mode is disabled |
### 6. Added environment variables
#### `$FZF_DIRECTION`
`$FZF_DIRECTION` is now exported to child processes, indicating the list direction of the current layout:
- `up` for the default layout
- `down` for `reverse` or `reverse-list`
This simplifies writing transform actions involving layout-dependent actions
like `{up,down}-match`, `{up,down}-selected`, and `toggle+{up,down}`.
```sh
fzf --raw --bind 'result:first+transform:[[ $FZF_RAW = 0 ]] && echo $FZF_DIRECTION-match'
```
#### `$FZF_SOCK`
When fzf is listening on a Unix domain socket using `--listen`, the path to the
socket is exported as `$FZF_SOCK`, analogous to `$FZF_PORT` for TCP sockets.
#### `$FZF_RAW`
As described above, `$FZF_RAW` is now exported to child processes in raw mode,
indicating whether the current item is a match (`1`) or not (`0`). It is not
defined when not in raw mode.
#### `$FZF_CTRL_R_COMMAND`
You can opt-out `CTRL-R` binding from the shell integration by setting
`FZF_CTRL_R_COMMAND` to an empty string. Setting it to any other value is not
supported and will result in a warning.
```sh
# Disable the CTRL-R binding from the shell integration
FZF_CTRL_R_COMMAND= eval "$(fzf --bash)"
```
### 7. Added key support for `--bind`
Pull request [#3996](https://github.com/junegunn/fzf/pull/3996) added support
for many additional keys for `--bind` option, such as `ctrl-backspace`.
### 8. Breaking changes
#### Hiding the gutter column
In the previous versions, the recommended way to hide the gutter column was to
set `--color gutter:-1`. That's because the gutter column was just a space
character, reversed. But now that it's using a visible character (``), applying
the default color is no longer enough to hide it. Instead, you can set it to
a space character.
```sh
# Hide the gutter column
fzf --gutter ' '
# Classic style
fzf --gutter ' ' --color gutter:reverse
```
#### `--color` option
In the previous versions, some elements had default style attributes applied and
you would have to explicitly unset them with `regular` attribute if you wanted
to reset them. This is no longer needed now, as the default style attributes
are applied only when you do not specify any color or style for that element.
```sh
# No 'dim', just red and italic.
fzf --ghost 'Type to search' --color ghost:red:italic
```
#### Compatibility changes
Starting with this release, fzf is built with Go 1.23. Support for some old OS versions has been dropped.
See https://go.dev/wiki/MinimumRequirements.
0.65.2
------
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2013-2025 Junegunn Choi
Copyright (c) 2013-2026 Junegunn Choi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+23 -6
View File
@@ -1,10 +1,20 @@
GO ?= go
DOCKER ?= docker
GOOS ?= $(shell $(GO) env GOOS)
MAKEFILE := $(realpath $(lastword $(MAKEFILE_LIST)))
ROOT_DIR := $(shell dirname $(MAKEFILE))
SOURCES := $(wildcard *.go src/*.go src/*/*.go shell/*sh man/man1/*.1) $(MAKEFILE)
BASH_SCRIPTS := $(ROOT_DIR)/bin/fzf-preview.sh \
$(ROOT_DIR)/bin/fzf-tmux \
$(ROOT_DIR)/install \
$(ROOT_DIR)/uninstall \
$(ROOT_DIR)/shell/common.sh \
$(ROOT_DIR)/shell/update.sh \
$(ROOT_DIR)/shell/completion.bash \
$(ROOT_DIR)/shell/key-bindings.bash
ifdef FZF_VERSION
VERSION := $(FZF_VERSION)
else
@@ -43,6 +53,8 @@ ifeq ($(UNAME_M),x86_64)
BINARY := $(BINARY64)
else ifeq ($(UNAME_M),amd64)
BINARY := $(BINARY64)
else ifeq ($(UNAME_M),i86pc)
BINARY := $(BINARY64)
else ifeq ($(UNAME_M),s390x)
BINARY := $(BINARYS390)
else ifeq ($(UNAME_M),i686)
@@ -88,9 +100,14 @@ itest:
bench:
cd src && SHELL=/bin/sh GOOS= $(GO) test -v -tags "$(TAGS)" -run=Bench -bench=. -benchmem
lint: $(SOURCES) test/*.rb test/lib/*.rb
lint: $(SOURCES) test/*.rb test/lib/*.rb ${BASH_SCRIPTS}
[ -z "$$(gofmt -s -d src)" ] || (gofmt -s -d src; exit 1)
bundle exec rubocop -a --require rubocop-minitest --require rubocop-performance
shell/update.sh --check ${BASH_SCRIPTS}
fmt: $(SOURCES) $(BASH_SCRIPTS)
gofmt -s -w src
shell/update.sh ${BASH_SCRIPTS}
install: bin/fzf
@@ -178,15 +195,15 @@ bin/fzf: target/$(BINARY) | bin
cp -f target/$(BINARY) bin/fzf
docker:
docker build -t fzf-ubuntu .
docker run -it fzf-ubuntu tmux
$(DOCKER) build -t fzf-ubuntu .
$(DOCKER) run -it fzf-ubuntu tmux
docker-test:
docker build -t fzf-ubuntu .
docker run -it fzf-ubuntu
$(DOCKER) build -t fzf-ubuntu .
$(DOCKER) run -it fzf-ubuntu
update:
$(GO) get -u
$(GO) mod tidy
.PHONY: all generate build release test itest bench lint install clean docker docker-test update
.PHONY: all generate build release test itest bench lint install clean docker docker-test update fmt
+1 -1
View File
@@ -493,4 +493,4 @@ autocmd FileType fzf set laststatus=0 noshowmode noruler
The MIT License (MIT)
Copyright (c) 2013-2025 Junegunn Choi
Copyright (c) 2013-2026 Junegunn Choi
+176 -69
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -49,9 +49,9 @@ if [[ ! $type =~ image/ ]]; then
fi
dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}
if [[ $dim = x ]]; then
if [[ $dim == x ]]; then
dim=$(stty size < /dev/tty | awk '{print $2 "x" $1}')
elif ! [[ $KITTY_WINDOW_ID ]] && (( FZF_PREVIEW_TOP + FZF_PREVIEW_LINES == $(stty size < /dev/tty | awk '{print $1}') )); then
elif ! [[ $KITTY_WINDOW_ID ]] && ((FZF_PREVIEW_TOP + FZF_PREVIEW_LINES == $(stty size < /dev/tty | awk '{print $1}'))); then
# Avoid scrolling issue when the Sixel image touches the bottom of the screen
# * https://github.com/junegunn/fzf/issues/2544
dim=${FZF_PREVIEW_COLUMNS}x$((FZF_PREVIEW_LINES - 1))
+50 -48
View File
@@ -8,7 +8,7 @@ fail() {
}
fzf="$(command which fzf)" || fzf="$(dirname "$0")/fzf"
[[ -x "$fzf" ]] || fail 'fzf executable not found'
[[ -x $fzf ]] || fail 'fzf executable not found'
args=()
opt=""
@@ -16,8 +16,8 @@ skip=""
swap=""
close=""
term=""
[[ -n "$LINES" ]] && lines=$LINES || lines=$(tput lines) || lines=$(tmux display-message -p "#{pane_height}")
[[ -n "$COLUMNS" ]] && columns=$COLUMNS || columns=$(tput cols) || columns=$(tmux display-message -p "#{pane_width}")
[[ -n $LINES ]] && lines=$LINES || lines=$(tput lines) || lines=$(tmux display-message -p "#{pane_height}")
[[ -n $COLUMNS ]] && columns=$COLUMNS || columns=$(tput cols) || columns=$(tmux display-message -p "#{pane_width}")
tmux_version=$(tmux -V | sed 's/[^0-9.]//g')
tmux_32=$(awk '{print ($1 >= 3.2)}' <<< "$tmux_version" 2> /dev/null || bc -l <<< "$tmux_version >= 3.2")
@@ -47,7 +47,7 @@ help() {
while [[ $# -gt 0 ]]; do
arg="$1"
shift
[[ -z "$skip" ]] && case "$arg" in
[[ -z $skip ]] && case "$arg" in
-)
term=1
;;
@@ -58,19 +58,19 @@ while [[ $# -gt 0 ]]; do
echo "fzf-tmux (with fzf $("$fzf" --version))"
exit
;;
-p*|-w*|-h*|-x*|-y*|-d*|-u*|-r*|-l*)
if [[ "$arg" =~ ^-[pwhxy] ]]; then
[[ "$opt" =~ "-E" ]] || opt="-E"
elif [[ "$arg" =~ ^.[lr] ]]; then
-p* | -w* | -h* | -x* | -y* | -d* | -u* | -r* | -l*)
if [[ $arg =~ ^-[pwhxy] ]]; then
[[ $opt =~ "-E" ]] || opt="-E"
elif [[ $arg =~ ^.[lr] ]]; then
opt="-h"
if [[ "$arg" =~ ^.l ]]; then
if [[ $arg =~ ^.l ]]; then
opt="$opt -d"
swap="; swap-pane -D ; select-pane -L"
close="; tmux swap-pane -D"
fi
else
opt=""
if [[ "$arg" =~ ^.u ]]; then
if [[ $arg =~ ^.u ]]; then
opt="$opt -d"
swap="; swap-pane -D ; select-pane -U"
close="; tmux swap-pane -D"
@@ -79,7 +79,7 @@ while [[ $# -gt 0 ]]; do
if [[ ${#arg} -gt 2 ]]; then
size="${arg:2}"
else
if [[ "$1" =~ ^[0-9%,]+$ ]] || [[ "$1" =~ ^[A-Z]$ ]]; then
if [[ $1 =~ ^[0-9%,]+$ ]] || [[ $1 =~ ^[A-Z]$ ]]; then
size="$1"
shift
else
@@ -87,37 +87,37 @@ while [[ $# -gt 0 ]]; do
fi
fi
if [[ "$arg" =~ ^-p ]]; then
if [[ -n "$size" ]]; then
if [[ $arg =~ ^-p ]]; then
if [[ -n $size ]]; then
w=${size%%,*}
h=${size##*,}
opt="$opt -w$w -h$h"
fi
elif [[ "$arg" =~ ^-[whxy] ]]; then
elif [[ $arg =~ ^-[whxy] ]]; then
opt="$opt ${arg:0:2}$size"
elif [[ "$size" =~ %$ ]]; then
size=${size:0:((${#size}-1))}
if [[ $tmux_32 = 1 ]]; then
if [[ -n "$swap" ]]; then
opt="$opt -l $(( 100 - size ))%"
elif [[ $size =~ %$ ]]; then
size=${size:0:${#size}-1}
if [[ $tmux_32 == 1 ]]; then
if [[ -n $swap ]]; then
opt="$opt -l $((100 - size))%"
else
opt="$opt -l $size%"
fi
else
if [[ -n "$swap" ]]; then
opt="$opt -p $(( 100 - size ))"
if [[ -n $swap ]]; then
opt="$opt -p $((100 - size))"
else
opt="$opt -p $size"
fi
fi
else
if [[ -n "$swap" ]]; then
if [[ "$arg" =~ ^.l ]]; then
if [[ -n $swap ]]; then
if [[ $arg =~ ^.l ]]; then
max=$columns
else
max=$lines
fi
size=$(( max - size ))
size=$((max - size))
[[ $size -lt 0 ]] && size=0
opt="$opt -l $size"
else
@@ -135,10 +135,10 @@ while [[ $# -gt 0 ]]; do
args+=("$arg")
;;
esac
[[ -n "$skip" ]] && args+=("$arg")
[[ -n $skip ]] && args+=("$arg")
done
if [[ -z "$TMUX" ]]; then
if [[ -z $TMUX ]]; then
"$fzf" "${args[@]}"
exit $?
fi
@@ -149,7 +149,7 @@ fi
args=("${args[@]}" "--no-height" "--bind=ctrl-z:ignore" "--no-tmux")
# Handle zoomed tmux pane without popup options by moving it to a temp window
if [[ ! "$opt" =~ "-E" ]] && tmux list-panes -F '#F' | grep -q Z; then
if [[ ! $opt =~ "-E" ]] && tmux list-panes -F '#F' | grep -q Z; then
zoomed_without_popup=1
original_window=$(tmux display-message -p "#{window_id}")
tmp_window=$(tmux new-window -d -P -F "#{window_id}" "bash -c 'while :; do for c in \\| / - '\\;' do sleep 0.2; printf \"\\r\$c fzf-tmux is running\\r\"; done; done'")
@@ -159,28 +159,27 @@ fi
set -e
# Clean up named pipes on exit
id=$RANDOM
argsf="${TMPDIR:-/tmp}/fzf-args-$id"
fifo1="${TMPDIR:-/tmp}/fzf-fifo1-$id"
fifo2="${TMPDIR:-/tmp}/fzf-fifo2-$id"
fifo3="${TMPDIR:-/tmp}/fzf-fifo3-$id"
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/fzf-tmux-XXXXXX")
argsf="$tmpdir/args"
fifo1="$tmpdir/fifo1"
fifo2="$tmpdir/fifo2"
fifo3="$tmpdir/fifo3"
if tmux_win_opts=$(tmux show-options -p remain-on-exit \; show-options -p synchronize-panes 2> /dev/null); then
tmux_win_opts=( $(sed '/ off/d; s/synchronize-panes/set-option -p synchronize-panes/; s/remain-on-exit/set-option -p remain-on-exit/; s/$/ \\;/' <<< "$tmux_win_opts") )
tmux_win_opts=($(sed '/ off/d; s/synchronize-panes/set-option -p synchronize-panes/; s/remain-on-exit/set-option -p remain-on-exit/; s/$/ \\;/' <<< "$tmux_win_opts"))
tmux_off_opts='; set-option -p synchronize-panes off ; set-option -p remain-on-exit off'
else
tmux_win_opts=( $(tmux show-window-options remain-on-exit \; show-window-options synchronize-panes | sed '/ off/d; s/^/set-window-option /; s/$/ \\;/') )
tmux_win_opts=($(tmux show-window-options remain-on-exit \; show-window-options synchronize-panes | sed '/ off/d; s/^/set-window-option /; s/$/ \\;/'))
tmux_off_opts='; set-window-option synchronize-panes off ; set-window-option remain-on-exit off'
fi
cleanup() {
\rm -f $argsf $fifo1 $fifo2 $fifo3
\rm -rf "$tmpdir"
# Restore tmux window options
if [[ "${#tmux_win_opts[@]}" -gt 1 ]]; then
if [[ ${#tmux_win_opts[@]} -gt 1 ]]; then
eval "tmux ${tmux_win_opts[*]}"
fi
# Remove temp window if we were zoomed without popup options
if [[ -n "$zoomed_without_popup" ]]; then
if [[ -n $zoomed_without_popup ]]; then
tmux display-message -p "#{window_id}" > /dev/null
tmux swap-pane -t $original_window \; \
select-window -t $original_window \; \
@@ -196,11 +195,11 @@ cleanup() {
trap 'cleanup 1' SIGUSR1
trap 'cleanup' EXIT
envs="export TERM=$TERM "
if [[ "$opt" =~ "-E" ]]; then
if [[ $tmux_version = 3.2 ]]; then
envs="export TERM=$(printf %q "$TERM") "
if [[ $opt =~ "-E" ]]; then
if [[ $tmux_version == 3.2 ]]; then
FZF_DEFAULT_OPTS="--margin 0,1 $FZF_DEFAULT_OPTS"
elif [[ $tmux_32 = 1 ]]; then
elif [[ $tmux_32 == 1 ]]; then
FZF_DEFAULT_OPTS="--border $FZF_DEFAULT_OPTS"
opt="-B $opt"
else
@@ -211,8 +210,8 @@ fi
envs="$envs FZF_DEFAULT_COMMAND=$(printf %q "$FZF_DEFAULT_COMMAND")"
envs="$envs FZF_DEFAULT_OPTS=$(printf %q "$FZF_DEFAULT_OPTS")"
envs="$envs FZF_DEFAULT_OPTS_FILE=$(printf %q "$FZF_DEFAULT_OPTS_FILE")"
[[ -n "$RUNEWIDTH_EASTASIAN" ]] && envs="$envs RUNEWIDTH_EASTASIAN=$(printf %q "$RUNEWIDTH_EASTASIAN")"
[[ -n "$BAT_THEME" ]] && envs="$envs BAT_THEME=$(printf %q "$BAT_THEME")"
[[ -n $RUNEWIDTH_EASTASIAN ]] && envs="$envs RUNEWIDTH_EASTASIAN=$(printf %q "$RUNEWIDTH_EASTASIAN")"
[[ -n $BAT_THEME ]] && envs="$envs BAT_THEME=$(printf %q "$BAT_THEME")"
echo "$envs;" > "$argsf"
# Build arguments to fzf
@@ -224,9 +223,9 @@ close="; trap - EXIT SIGINT SIGTERM $close"
export TMUX=$(cut -d , -f 1,2 <<< "$TMUX")
mkfifo -m o+w $fifo2
if [[ "$opt" =~ "-E" ]]; then
if [[ $opt =~ "-E" ]]; then
cat $fifo2 &
if [[ -n "$term" ]] || [[ -t 0 ]]; then
if [[ -n $term ]] || [[ -t 0 ]]; then
cat <<< "\"$fzf\" $opts > $fifo2; out=\$? $close; exit \$out" >> $argsf
else
mkfifo $fifo1
@@ -239,7 +238,7 @@ if [[ "$opt" =~ "-E" ]]; then
fi
mkfifo -m o+w $fifo3
if [[ -n "$term" ]] || [[ -t 0 ]]; then
if [[ -n $term ]] || [[ -t 0 ]]; then
cat <<< "\"$fzf\" $opts > $fifo2; echo \$? > $fifo3 $close" >> $argsf
else
mkfifo $fifo1
@@ -249,6 +248,9 @@ fi
tmux \
split-window -c "$PWD" $opt "bash -c 'exec -a fzf bash $argsf'" $swap \
$tmux_off_opts \
> /dev/null 2>&1 || { "$fzf" "${args[@]}"; exit $?; }
> /dev/null 2>&1 || {
"$fzf" "${args[@]}"
exit $?
}
cat $fifo2
exit "$(cat $fifo3)"
+3 -5
View File
@@ -112,7 +112,7 @@ the whole if we start off with `:FZF` command.
" Bang version starts fzf in fullscreen mode
:FZF!
<
Similarly to {ctrlp.vim}{3}, use enter key, CTRL-T, CTRL-X or CTRL-V to open
Similarly to {ctrlp.vim}{3}, use Enter key, CTRL-T, CTRL-X or CTRL-V to open
selected files in the current window, in new tabs, in horizontal splits, or in
vertical splits respectively.
@@ -218,7 +218,6 @@ list:
`fg` / `bg` / `hl` | Item (foreground / background / highlight)
`fg+` / `bg+` / `hl+` | Current item (foreground / background / highlight)
`preview-fg` / `preview-bg` | Preview window text and background
`hl` / `hl+` | Highlighted substrings (normal / current)
`gutter` | Background of the gutter on the left
`pointer` | Pointer to the current line ( `>` )
`marker` | Multi-select marker ( `>` )
@@ -229,7 +228,6 @@ list:
`query` | Query string
`disabled` | Query string when search is disabled
`prompt` | Prompt before query ( `> ` )
`pointer` | Pointer to the current line ( `>` )
----------------------------+------------------------------------------------------
- `component` specifies the component (`fg` / `bg`) from which to extract the
color when considering each of the following highlight groups
@@ -245,7 +243,7 @@ if it exists, - otherwise use the `fg` attribute of the `Comment` highlight
group if it exists, - otherwise fall back to the default color settings for
the prompt.
You can examine the color option generated according the setting by printing
You can examine the color option generated according to the setting by printing
the result of `fzf#wrap()` function like so:
>
:echo fzf#wrap()
@@ -503,7 +501,7 @@ LICENSE *fzf-license*
The MIT License (MIT)
Copyright (c) 2013-2025 Junegunn Choi
Copyright (c) 2013-2026 Junegunn Choi
==============================================================================
vim:tw=78:sw=2:ts=2:ft=help:norl:nowrap:
+124 -72
View File
@@ -2,7 +2,7 @@
set -u
version=0.65.2
version=0.71.0
auto_completion=
key_bindings=
update_config=2
@@ -46,16 +46,16 @@ for opt in "$@"; do
prefix_expand=${XDG_CONFIG_HOME:-$HOME/.config}/fzf/fzf
mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/fzf"
;;
--key-bindings) key_bindings=1 ;;
--no-key-bindings) key_bindings=0 ;;
--completion) auto_completion=1 ;;
--no-completion) auto_completion=0 ;;
--update-rc) update_config=1 ;;
--no-update-rc) update_config=0 ;;
--bin) ;;
--no-bash) shells=${shells/bash/} ;;
--no-zsh) shells=${shells/zsh/} ;;
--no-fish) shells=${shells/fish/} ;;
--key-bindings) key_bindings=1 ;;
--no-key-bindings) key_bindings=0 ;;
--completion) auto_completion=1 ;;
--no-completion) auto_completion=0 ;;
--update-rc) update_config=1 ;;
--no-update-rc) update_config=0 ;;
--bin) ;;
--no-bash) shells=${shells/bash/} ;;
--no-zsh) shells=${shells/zsh/} ;;
--no-fish) shells=${shells/fish/} ;;
*)
echo "unknown option: $opt"
help
@@ -104,7 +104,7 @@ check_binary() {
link_fzf_in_path() {
if which_fzf="$(command -v fzf)"; then
echo " - Found in \$PATH"
echo ' - Found in $PATH'
echo " - Creating symlink: bin/fzf -> $which_fzf"
(cd "$fzf_base"/bin && rm -f fzf && ln -sf "$which_fzf" fzf)
check_binary && return
@@ -112,24 +112,29 @@ link_fzf_in_path() {
return 1
}
tar_opts="-xzf -"
if tar --no-same-owner -tf /dev/null 2> /dev/null; then
tar_opts="--no-same-owner $tar_opts"
fi
try_curl() {
command -v curl > /dev/null &&
if [[ $1 =~ tar.gz$ ]]; then
curl -fL $1 | tar --no-same-owner -xzf -
else
local temp=${TMPDIR:-/tmp}/fzf.zip
curl -fLo "$temp" $1 && unzip -o "$temp" && rm -f "$temp"
fi
if [[ $1 =~ tar.gz$ ]]; then
curl -fL $1 | tar $tar_opts
else
local temp=${TMPDIR:-/tmp}/fzf.zip
curl -fLo "$temp" $1 && unzip -o "$temp" && rm -f "$temp"
fi
}
try_wget() {
command -v wget > /dev/null &&
if [[ $1 =~ tar.gz$ ]]; then
wget -O - $1 | tar --no-same-owner -xzf -
else
local temp=${TMPDIR:-/tmp}/fzf.zip
wget -O "$temp" $1 && unzip -o "$temp" && rm -f "$temp"
fi
if [[ $1 =~ tar.gz$ ]]; then
wget -O - $1 | tar $tar_opts
else
local temp=${TMPDIR:-/tmp}/fzf.zip
wget -O "$temp" $1 && unzip -o "$temp" && rm -f "$temp"
fi
}
download() {
@@ -164,29 +169,30 @@ download() {
}
# Try to download binary executable
archi=$(uname -smo 2>/dev/null || uname -sm)
archi=$(uname -smo 2> /dev/null || uname -sm)
binary_available=1
binary_error=""
case "$archi" in
Darwin\ arm64*) download fzf-$version-darwin_arm64.tar.gz ;;
Darwin\ x86_64*) download fzf-$version-darwin_amd64.tar.gz ;;
Linux\ armv5*) download fzf-$version-linux_armv5.tar.gz ;;
Linux\ armv6*) download fzf-$version-linux_armv6.tar.gz ;;
Linux\ armv7*) download fzf-$version-linux_armv7.tar.gz ;;
Linux\ armv8*) download fzf-$version-linux_arm64.tar.gz ;;
Darwin\ arm64*) download fzf-$version-darwin_arm64.tar.gz ;;
Darwin\ x86_64*) download fzf-$version-darwin_amd64.tar.gz ;;
Linux\ armv5*) download fzf-$version-linux_armv5.tar.gz ;;
Linux\ armv6*) download fzf-$version-linux_armv6.tar.gz ;;
Linux\ armv7*) download fzf-$version-linux_armv7.tar.gz ;;
Linux\ armv8*) download fzf-$version-linux_arm64.tar.gz ;;
Linux\ aarch64\ Android) download fzf-$version-android_arm64.tar.gz ;;
Linux\ aarch64*) download fzf-$version-linux_arm64.tar.gz ;;
Linux\ loongarch64*) download fzf-$version-linux_loong64.tar.gz ;;
Linux\ ppc64le*) download fzf-$version-linux_ppc64le.tar.gz ;;
Linux\ *64*) download fzf-$version-linux_amd64.tar.gz ;;
Linux\ s390x*) download fzf-$version-linux_s390x.tar.gz ;;
FreeBSD\ *64*) download fzf-$version-freebsd_amd64.tar.gz ;;
OpenBSD\ *64*) download fzf-$version-openbsd_amd64.tar.gz ;;
CYGWIN*\ *64*) download fzf-$version-windows_amd64.zip ;;
MINGW*\ *64*) download fzf-$version-windows_amd64.zip ;;
MSYS*\ *64*) download fzf-$version-windows_amd64.zip ;;
Windows*\ *64*) download fzf-$version-windows_amd64.zip ;;
*) binary_available=0 binary_error=1 ;;
Linux\ aarch64*) download fzf-$version-linux_arm64.tar.gz ;;
Linux\ loongarch64*) download fzf-$version-linux_loong64.tar.gz ;;
Linux\ riscv64*) download fzf-$version-linux_riscv64.tar.gz ;;
Linux\ ppc64le*) download fzf-$version-linux_ppc64le.tar.gz ;;
Linux\ *64*) download fzf-$version-linux_amd64.tar.gz ;;
Linux\ s390x*) download fzf-$version-linux_s390x.tar.gz ;;
FreeBSD\ *64*) download fzf-$version-freebsd_amd64.tar.gz ;;
OpenBSD\ *64*) download fzf-$version-openbsd_amd64.tar.gz ;;
CYGWIN*\ *64*) download fzf-$version-windows_amd64.zip ;;
MINGW*\ *64*) download fzf-$version-windows_amd64.zip ;;
MSYS*\ *64*) download fzf-$version-windows_amd64.zip ;;
Windows*\ *64*) download fzf-$version-windows_amd64.zip ;;
*) binary_available=0 binary_error=1 ;;
esac
cd "$fzf_base"
@@ -215,7 +221,7 @@ if [ -n "$binary_error" ]; then
fi
fi
[[ "$*" =~ "--bin" ]] && exit 0
[[ $* =~ "--bin" ]] && exit 0
for s in $shells; do
if ! command -v "$s" > /dev/null; then
@@ -242,16 +248,16 @@ fi
echo
for shell in $shells; do
[[ "$shell" = fish ]] && continue
fzf_completion="source \"$fzf_base/shell/completion.${shell}\""
fzf_key_bindings="source \"$fzf_base/shell/key-bindings.${shell}\""
[[ $shell == fish ]] && continue
src=${prefix_expand}.${shell}
echo -n "Generate $src ... "
fzf_completion="source \"$fzf_base/shell/completion.${shell}\""
if [ $auto_completion -eq 0 ]; then
fzf_completion="# $fzf_completion"
fi
fzf_key_bindings="source \"$fzf_base/shell/key-bindings.${shell}\""
if [ $key_bindings -eq 0 ]; then
fzf_key_bindings="# $fzf_key_bindings"
fi
@@ -266,7 +272,7 @@ fi
EOF
if [[ $auto_completion -eq 1 ]] && [[ $key_bindings -eq 1 ]]; then
if [[ "$shell" = zsh ]]; then
if [[ $shell == zsh ]]; then
echo "source <(fzf --$shell)" >> "$src"
else
echo "eval \"\$(fzf --$shell)\"" >> "$src"
@@ -286,7 +292,7 @@ EOF
done
# fish
if [[ "$shells" =~ fish ]]; then
if [[ $shells =~ fish ]]; then
echo -n "Update fish_user_paths ... "
fish << EOF
echo \$fish_user_paths | \grep "$fzf_base"/bin > /dev/null
@@ -301,15 +307,16 @@ append_line() {
line="$2"
file="$3"
pat="${4:-}"
at_lno="${5:-}"
lines=""
echo "Update $file:"
echo " - $line"
if [ -f "$file" ]; then
if [ $# -lt 4 ]; then
lines=$(\grep -nF "$line" "$file")
else
if [[ -n $pat ]]; then
lines=$(\grep -nF "$pat" "$file")
else
lines=$(\grep -nF "${line#"${line%%[![:space:]]*}"}" "$file")
fi
fi
@@ -318,17 +325,21 @@ append_line() {
sed 's/^/ Line /' <<< "$lines"
update=0
if ! \grep -qv "^[0-9]*:[[:space:]]*#" <<< "$lines" ; then
if ! \grep -qv "^[0-9]*:[[:space:]]*#" <<< "$lines"; then
echo " - But they all seem to be commented"
ask " - Continue modifying $file?"
ask " - Continue modifying $file?"
update=$?
fi
fi
set -e
if [ "$update" -eq 1 ]; then
[ -f "$file" ] && echo >> "$file"
echo "$line" >> "$file"
if [[ -z $at_lno ]]; then
[ -f "$file" ] && echo >> "$file"
echo "$line" >> "$file"
else
sed -i.~fzf_bak "${at_lno}a\\"$'\n'"$line" "$file" && rm "$file.~fzf_bak"
fi
echo " + Added"
else
echo " ~ Skipped"
@@ -356,43 +367,84 @@ if [ $update_config -eq 2 ]; then
fi
echo
for shell in $shells; do
[[ "$shell" = fish ]] && continue
[[ $shell == fish ]] && continue
[ $shell = zsh ] && dest=${ZDOTDIR:-~}/.zshrc || dest=~/.bashrc
append_line $update_config "[ -f ${prefix}.${shell} ] && source ${prefix}.${shell}" "$dest" "${prefix}.${shell}"
done
if [ $key_bindings -eq 1 ] && [[ "$shells" =~ fish ]]; then
if [[ $shells =~ fish ]]; then
bind_file="${fish_dir}/functions/fish_user_key_bindings.fish"
if [ ! -e "$bind_file" ]; then
mkdir -p "${fish_dir}/functions"
create_file "$bind_file" \
'function fish_user_key_bindings' \
' fzf --fish | source' \
'end'
if [[ $key_bindings -eq 1 || $auto_completion -eq 1 ]]; then
mkdir -p "${fish_dir}/functions"
if [[ $key_bindings -eq 1 && $auto_completion -eq 1 ]]; then
create_file "$bind_file" \
'function fish_user_key_bindings' \
' fzf --fish | source' \
'end'
elif [[ $key_bindings -eq 1 ]]; then
create_file "$bind_file" \
'function fish_user_key_bindings' \
" $fzf_key_bindings" \
'end'
elif [[ $auto_completion -eq 1 ]]; then
create_file "$bind_file" \
'function fish_user_key_bindings' \
" $fzf_completion" \
'end'
fi
lno_func=$(\grep -nF "function fish_user_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ')
else
lno_func=0
fi
else
echo "Check $bind_file:"
lno=$(\grep -nF "fzf_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ')
if [[ -n $lno ]]; then
echo " ** Found 'fzf_key_bindings' in line #$lno"
echo " ** You have to replace the line to 'fzf --fish | source'"
lno_func=$(\grep -nF "function fish_user_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ')
if [[ -z $lno_func ]]; then
echo -e "function fish_user_key_bindings\nend" >> "$bind_file"
lno_func=$(\grep -nF "function fish_user_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ')
fi
lno_keys=$(\grep -nF "fzf_key_bindings" "$bind_file" | sed 's/:.*//' | tr '\n' ' ')
if [[ -n $lno_keys ]]; then
echo " ** Found 'fzf_key_bindings' in line #$lno_keys"
if [[ $key_bindings -eq 1 && $auto_completion -eq 1 ]]; then
echo " ** You have to replace the line to 'fzf --fish | source'"
elif [[ $key_bindings -eq 1 ]]; then
echo " ** You have to replace the line to '$fzf_key_bindings'"
else
echo " ** You have to remove the line"
fi
echo
else
echo " - Clear"
echo
append_line $update_config "fzf --fish | source" "$bind_file"
if [[ $key_bindings -eq 1 && $auto_completion -eq 1 ]]; then
sed -i.~fzf_bak "\#$fzf_completion#d" "$bind_file" && rm "$bind_file.~fzf_bak"
sed -i.~fzf_bak "\#$fzf_key_bindings#d" "$bind_file" && rm "$bind_file.~fzf_bak"
append_line $update_config " fzf --fish | source" "$bind_file" "" "$lno_func"
else
sed -i.~fzf_bak '/fzf --fish \| source/d' "$bind_file" && rm "$bind_file.~fzf_bak"
if [[ $key_bindings -eq 1 ]]; then
sed -i.~fzf_bak "\#$fzf_completion#d" "$bind_file" && rm "$bind_file.~fzf_bak"
append_line $update_config " $fzf_key_bindings" "$bind_file" "" "$lno_func"
elif [[ $auto_completion -eq 1 ]]; then
sed -i.~fzf_bak "\#$fzf_key_bindings#d" "$bind_file" && rm "$bind_file.~fzf_bak"
append_line $update_config " $fzf_completion" "$bind_file" "" "$lno_func"
fi
fi
fi
fi
fi
if [ $update_config -eq 1 ]; then
echo 'Finished. Restart your shell or reload config file.'
if [[ "$shells" =~ bash ]]; then
if [[ $shells =~ bash ]]; then
echo -n ' source ~/.bashrc # bash'
[[ "$archi" =~ Darwin ]] && echo -n ' (.bashrc should be loaded from .bash_profile)'
[[ $archi =~ Darwin ]] && echo -n ' (.bashrc should be loaded from .bash_profile)'
echo
fi
[[ "$shells" =~ zsh ]] && echo " source ${ZDOTDIR:-~}/.zshrc # zsh"
[[ "$shells" =~ fish ]] && [ $key_bindings -eq 1 ] && echo ' fzf_key_bindings # fish'
[[ $shells =~ zsh ]] && echo " source ${ZDOTDIR:-~}/.zshrc # zsh"
[[ $shells =~ fish && $lno_func -ne 0 ]] && echo ' fish_user_key_bindings # fish'
echo
echo 'Use uninstall script to remove fzf.'
echo
+1 -1
View File
@@ -1,4 +1,4 @@
$version="0.65.2"
$version="0.71.0"
$fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
+5 -2
View File
@@ -11,7 +11,7 @@ import (
"github.com/junegunn/fzf/src/protector"
)
var version = "0.65"
var version = "0.71"
var revision = "devel"
//go:embed shell/key-bindings.bash
@@ -29,6 +29,9 @@ var zshCompletion []byte
//go:embed shell/key-bindings.fish
var fishKeyBindings []byte
//go:embed shell/completion.fish
var fishCompletion []byte
//go:embed man/man1/fzf.1
var manPage []byte
@@ -65,7 +68,7 @@ func main() {
}
if options.Fish {
printScript("key-bindings.fish", fishKeyBindings)
fmt.Println("fzf_key_bindings")
printScript("completion.fish", fishCompletion)
return
}
if options.Help {
+2 -2
View File
@@ -1,7 +1,7 @@
.ig
The MIT License (MIT)
Copyright (c) 2013-2025 Junegunn Choi
Copyright (c) 2013-2026 Junegunn Choi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
..
.TH fzf\-tmux 1 "Aug 2025" "fzf 0.65.2" "fzf\-tmux - open fzf in tmux split pane"
.TH fzf\-tmux 1 "Apr 2026" "fzf 0.71.0" "fzf\-tmux - open fzf in tmux split pane"
.SH NAME
fzf\-tmux - open fzf in tmux split pane
+197 -44
View File
@@ -1,7 +1,7 @@
.ig
The MIT License (MIT)
Copyright (c) 2013-2025 Junegunn Choi
Copyright (c) 2013-2026 Junegunn Choi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
..
.TH fzf 1 "Aug 2025" "fzf 0.65.2" "fzf - a command-line fuzzy finder"
.TH fzf 1 "Apr 2026" "fzf 0.71.0" "fzf - a command-line fuzzy finder"
.SH NAME
fzf - a command-line fuzzy finder
@@ -134,6 +134,14 @@ e.g.
# Use template to rearrange fields
echo foo,bar,baz | fzf --delimiter , --with-nth '{n},{1},{3},{2},{1..2}'
.RE
.RS
\fBchange\-with\-nth\fR action is only available when \fB\-\-with\-nth\fR is set.
When \fB\-\-with\-nth\fR is used, fzf retains the original input lines in memory
so they can be re\-transformed on the fly (e.g. \fB\-\-with\-nth ..\fR to keep
the original presentation). This increases memory usage, so only use
\fB\-\-with\-nth\fR when you actually need field transformation.
.RE
.TP
.BI "\-\-accept\-nth=" "N[,..] or TEMPLATE"
Define which fields to print on accept. The last delimiter is stripped from the
@@ -272,6 +280,7 @@ color mappings. Each entry is separated by a comma and/or whitespaces.
\fBgutter \fRGutter on the left
\fBcurrent\-hl (hl+) \fRHighlighted substrings (current line)
\fBalt\-bg \fRAlternate background color to create striped lines
\fBalt\-gutter \fRAlternate gutter color to create the striped pattern
\fBquery (input\-fg) \fRQuery string
\fBghost \fRGhost text (\fB\-\-ghost\fR, \fBdim\fR applied by default)
\fBdisabled \fRQuery string when search is disabled (\fB\-\-disabled\fR)
@@ -299,6 +308,7 @@ color mappings. Each entry is separated by a comma and/or whitespaces.
\fBheader (header\-fg) \fRHeader
\fBfooter (footer\-fg) \fRFooter
\fBnth \fRParts of the line specified by \fB\-\-nth\fR (only supports attributes)
\fBnomatch \fRNon-matching items in raw mode (default: \fBdim\fR)
.B ANSI COLORS:
\fB\-1 \fRDefault terminal foreground/background color
@@ -324,9 +334,14 @@ color mappings. Each entry is separated by a comma and/or whitespaces.
\fB#rrggbb \fR24-bit colors
.B ANSI ATTRIBUTES: (Only applies to foreground colors)
\fBregular \fRClears previously set attributes; should precede the other ones
\fBregular \fRClear previously set attributes; should precede the other ones
\fBstrip \fRRemove colors
\fBbold\fR
\fBunderline\fR
\fBunderline-double\fR
\fBunderline-curly\fR
\fBunderline-dotted\fR
\fBunderline-dashed\fR
\fBreverse\fR
\fBdim\fR
\fBitalic\fR
@@ -369,7 +384,7 @@ Use black background
.SS DISPLAY MODE
.TP
.BI "\-\-height=" "[~]HEIGHT[%]"
.BI "\-\-height=" "[~][\-]HEIGHT[%]"
Display fzf window below the cursor with the given height instead of using
the full screen.
@@ -379,17 +394,19 @@ height minus the given value.
fzf \-\-height=\-1
When prefixed with \fB~\fR, fzf will automatically determine the height in the
range according to the input size.
range according to the input size. You can combine \fB~\fR with a negative
value.
# Will not take up 100% of the screen
seq 5 | fzf \-\-height=~100%
# Adapt to input size, up to terminal height minus 1
seq 5 | fzf \-\-height=~\-1
Adaptive height has the following limitations:
.br
* Cannot be used with top/bottom margin and padding given in percent size
.br
* Negative value is not allowed
.br
* It will not find the right size when there are multi-line items
.TP
@@ -400,25 +417,26 @@ layout options so that the specified number of items are visible in the list
section (default: \fB10+\fR).
Ignored when \fB\-\-height\fR is not specified or set as an absolute value.
.TP
.BI "\-\-tmux" "[=[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native]]"
Start fzf in a tmux popup (default \fBcenter,50%\fR). Requires tmux 3.3 or
later. This option is ignored if you are not running fzf inside tmux.
.BI "\-\-popup" "[=[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native]]"
Start fzf in a tmux popup or in a Zellij floating pane (default
\fBcenter,50%\fR). 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.
e.g.
\fB# Popup in the center with 70% width and height
fzf \-\-tmux 70%
fzf \-\-popup 70%
# Popup on the left with 40% width and 100% height
fzf \-\-tmux right,40%
fzf \-\-popup right,40%
# Popup on the bottom with 100% width and 30% height
fzf \-\-tmux bottom,30%
fzf \-\-popup bottom,30%
# Popup on the top with 80% width and 40% height
fzf \-\-tmux top,80%,40%
fzf \-\-popup top,80%,40%
# Popup with a native tmux border in the center with 80% width and height
fzf \-\-tmux center,80%,border\-native\fR
# Popup with a native tmux or Zellij border in the center with 80% width and height
fzf \-\-popup center,80%,border\-native\fR
.SS LAYOUT
.TP
@@ -586,8 +604,11 @@ Highlight the whole current line
.B "\-\-cycle"
Enable cyclic scroll
.TP
.B "\-\-wrap"
Enable line wrap
.BI "\-\-wrap" "[=MODE]"
Enable line wrap. \fIMODE\fR can be \fBchar\fR (default) or \fBword\fR.
\fBword\fR mode wraps lines at word boundaries (spaces and tabs) instead of
at arbitrary character positions. \fB\-\-wrap\-word\fR is a synonym for
\fB\-\-wrap=word\fR.
.TP
.BI "\-\-wrap\-sign" "=INDICATOR"
Indicator for wrapped lines. The default is '↳ ' or '> ' depending on
@@ -596,17 +617,56 @@ Indicator for wrapped lines. The default is '↳ ' or '> ' depending on
.B "\-\-no\-multi\-line"
Disable multi-line display of items when using \fB\-\-read0\fR
.TP
.B "\-\-track"
.B "\-\-raw"
Enable raw mode where non-matching items are also displayed in a dimmed color.
.TP
.BI "\-\-track"
Make fzf track the current selection when the result list is updated.
This can be useful when browsing logs using fzf with sorting disabled. It is
not recommended to use this option with \fB\-\-tac\fR as the resulting behavior
can be confusing. Also, consider using \fBtrack\fR action instead of this
option.
can be confusing.
When \fB\-\-id\-nth\fR is also set, fzf enables field\-based tracking across
\fBreload\fRs. See \fB\-\-id\-nth\fR for details.
Without \fB\-\-id\-nth\fR, \fB\-\-track\fR uses index\-based tracking that
does not persist across reloads.
.RS
e.g.
\fBgit log \-\-oneline \-\-graph \-\-color=always | nl |
\fB# Index\-based tracking (does not persist across reloads)
git log \-\-oneline \-\-graph \-\-color=always | nl |
fzf \-\-ansi \-\-track \-\-no\-sort \-\-layout=reverse\-list\fR
\fB# Track by first field (e.g. pod name) across reloads
kubectl get pods | fzf \-\-track \-\-id\-nth 1 \-\-header\-lines=1 \\
\-\-bind 'ctrl\-r:reload:kubectl get pods'\fR
.RE
.TP
.BI "\-\-id\-nth=" "N[,..]"
Define item identity fields for cross\-reload operations. When set, fzf
uses the specified fields to identify items across \fBreload\fR and
\fBreload\-sync\fR.
With \fB\-\-track\fR, fzf extracts the tracking key from the current item
using the nth expression and searches for a matching item in the reloaded list.
While searching, the UI is blocked (query input and cursor movement are
disabled, and the prompt is dimmed). With \fBreload\fR, the blocked state
clears as soon as the match is found in the stream. With \fBreload\-sync\fR,
the blocked state persists until the entire stream is complete. Press
\fBEscape\fR or \fBCtrl\-C\fR to cancel the blocked state without quitting fzf.
The info line shows \fB+T*\fR (or \fB+t*\fR for one\-off tracking) while
the search is in progress.
With \fB\-\-multi\fR, selected items are preserved across \fBreload\-sync\fR
by matching their identity fields in the reloaded list.
.RS
e.g.
\fB# Track and preserve selections by pod name across reloads
kubectl get pods | fzf \-\-multi \-\-track \-\-id\-nth 1 \-\-header\-lines=1 \\
\-\-bind 'ctrl\-r:reload\-sync:kubectl get pods'\fR
.RE
.TP
.B "\-\-tac"
@@ -624,9 +684,16 @@ Render empty lines between each item
The given string will be repeated to draw a horizontal line on each gap
(default: '┈' or '\-' depending on \fB\-\-no\-unicode\fR).
.TP
.BI "\-\-freeze\-left=" "N"
Number of fields to freeze on the left.
.TP
.BI "\-\-freeze\-right=" "N"
Number of fields to freeze on the right.
.TP
.B "\-\-keep\-right"
Keep the right end of the line visible when it's too long. Effective only when
the query string is empty.
the query string is empty. Use \fB\-\-freeze\-right=1\fR instead if you want
the last field to be always visible even with a non-empty query.
.TP
.BI "\-\-scroll\-off=" "LINES"
Number of screen lines to keep above or below when scrolling to the top or to
@@ -643,6 +710,12 @@ on the center of the screen.
.BI "\-\-jump\-labels=" "CHARS"
Label characters for \fBjump\fR mode.
.TP
.BI "\-\-gutter=" "CHAR"
Character used for the gutter column (default: '▌' unless \fB\-\-no\-unicode\fR is given)
.TP
.BI "\-\-gutter\-raw=" "CHAR"
Character used for the gutter column in raw mode (default: '▖' unless \fB\-\-no\-unicode\fR is given)
.TP
.BI "\-\-pointer=" "STR"
Pointer to the current line (default: '▌' or '>' depending on \fB\-\-no\-unicode\fR)
.TP
@@ -722,7 +795,7 @@ ENVIRONMENT VARIABLES EXPORTED TO CHILD PROCESSES.
e.g.
\fB# Prepend the current cursor position in yellow
fzf \-\-info\-command='echo \-e "\\x1b[33;1m$FZF_POS\\x1b[m/$FZF_INFO 💛"'\fR
fzf \-\-info\-command='printf "\\x1b[33;1m$FZF_POS\\x1b[m/$FZF_INFO 💛"'\fR
.TP
.B "\-\-no\-info"
@@ -822,6 +895,9 @@ e.g.
# This won't work properly without 'f' flag due to ARG_MAX limit.
seq 100000 | fzf \-\-preview "awk '{sum+=\\$1} END {print sum}' {*f}"\fR
\fB# Use {+f} to get the selected items as a line-separated list
seq 100 | fzf \-\-multi \-\-bind 'enter:become:cat {+f}'\fR
Also,
* \fB{q}\fR is replaced to the current query string
@@ -890,6 +966,11 @@ Should be used with one of the following \fB\-\-preview\-window\fR options.
.B * border\-bottom
.br
.TP
.BI "\-\-preview\-wrap\-sign" =INDICATOR
Indicator for wrapped lines in the preview window. If not set, the value of
\fB\-\-wrap\-sign\fR is used.
.TP
.BI "\-\-preview\-label\-pos" [=N[:top|bottom]]
Position of the border label on the border line of the preview window. Specify
@@ -900,7 +981,7 @@ default value 0 (or \fBcenter\fR) will put the label at the center of the
border line.
.TP
.BI "\-\-preview\-window=" "[POSITION][,SIZE[%]][,border\-STYLE][,[no]wrap][,[no]follow][,[no]cycle][,[no]info][,[no]hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default][,<SIZE_THRESHOLD(ALTERNATIVE_LAYOUT)]"
.BI "\-\-preview\-window=" "[POSITION][,SIZE[%]][,border\-STYLE][,[no]wrap][,wrap\-word][,[no]follow][,[no]cycle][,[no]info][,[no]hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default][,<SIZE_THRESHOLD(ALTERNATIVE_LAYOUT)]"
.RS
.B POSITION: (default: right)
@@ -918,7 +999,8 @@ default until \fBtoggle\-preview\fR action is triggered.
execute the command in the background.
* Long lines are truncated by default. Line wrap can be enabled with
\fBwrap\fR flag.
\fBwrap\fR flag. \fBwrap\-word\fR flag enables word-level wrapping, which
breaks lines at word boundaries instead of mid-word.
* Preview window will automatically scroll to the bottom when \fBfollow\fR
flag is set, similarly to how \fBtail \-f\fR works.
@@ -1018,7 +1100,17 @@ Print header before the prompt line. When both normal header and header lines
.TP
.BI "\-\-header\-border" [=STYLE]
Draw border around the header section. \fBline\fR style draws a single
separator line between the header window and the list section.
separator line between the header window and the list section. \fBinline\fR
style embeds the header inside the list border frame, joined to the list
section by a horizontal separator; it requires a \fB\-\-list\-border\fR
shape that has both top and bottom segments (rounded / sharp / bold /
double / block / thinblock / horizontal) and falls back to \fBline\fR
otherwise. When the list border also has side segments, the separator
joins them with T-junctions; \fBhorizontal\fR has no side borders, so the
separator is drawn without T-junction endpoints. Takes precedence over
\fB\-\-header\-first\fR (the section stays inside the list frame), and
when \fB\-\-header\-lines\fR is also set \fB\-\-header\-lines\-border\fR
must also be \fBinline\fR.
.TP
.BI "\-\-header\-label" [=LABEL]
@@ -1034,6 +1126,10 @@ Display header from \fB--header\-lines\fR with a separate border. Pass
\fBnone\fR to still separate the header lines but without a border. To combine
two headers, use \fB\-\-no\-header\-lines\-border\fR. \fBline\fR style draws
a single separator line between the header lines and the list section.
\fBinline\fR style embeds the header lines inside the list border frame
with a horizontal separator; it requires a \fB\-\-list\-border\fR shape
that has both top and bottom segments, falls back to \fBline\fR
otherwise.
.SS FOOTER
@@ -1047,7 +1143,10 @@ are not affected by \fB\-\-with\-nth\fR. ANSI color codes are processed even whe
.TP
.BI "\-\-footer\-border" [=STYLE]
Draw border around the footer section. \fBline\fR style draws a single
separator line between the footer and the list section.
separator line between the footer and the list section. \fBinline\fR style
embeds the footer inside the list border frame with a horizontal separator;
it requires a \fB\-\-list\-border\fR shape that has both top and bottom
segments and falls back to \fBline\fR otherwise.
.TP
.BI "\-\-footer\-label" [=LABEL]
@@ -1125,19 +1224,25 @@ On Windows, the default value is \fBcmd /s/c\fR when \fB$SHELL\fR is not
set.
.TP
.B "\-\-listen[=[ADDR:]PORT]" "\-\-listen\-unsafe[=[ADDR:]PORT]"
Start HTTP server and listen on the given address. It allows external processes
to send actions to perform via POST method.
.B "\-\-listen[=SOCKET_PATH|[ADDR:]PORT]" "\-\-listen\-unsafe[=[ADDR:]PORT]"
Start HTTP server and listen on the given address or Unix socket. It allows
external processes to send actions to perform via POST method and query the
program state via GET method. For the argument to be recognized as a socket
path, it must have \fB.sock\fR extension.
- If the port number is omitted or given as 0, fzf will automatically choose
a port and export it as \fBFZF_PORT\fR environment variable to the child processes
a port and export it as \fBFZF_PORT\fR environment variable to the child processes.
- If a Unix socket path is given, fzf will create a Unix domain socket at the
given path. The existing file will be removed. The path to the socket file
is exported as \fBFZF_SOCK\fR environment variable.
- If \fBFZF_API_KEY\fR environment variable is set, the server would require
sending an API key with the same value in the \fBx\-api\-key\fR HTTP header
sending an API key with the same value in the \fBx\-api\-key\fR HTTP header.
- \fBFZF_API_KEY\fR is required for a non-localhost listen address
- \fBFZF_API_KEY\fR is required for a non-localhost listen address.
- To allow remote process execution, use \fB\-\-listen\-unsafe\fR
- To allow remote process execution, use \fB\-\-listen\-unsafe\fR.
e.g.
\fB# Start HTTP server on port 6266
@@ -1176,6 +1281,30 @@ e.g.
'
\fR
Here is an example script that uses a Unix socket instead of a TCP port.
\fB
fzf --listen=/tmp/fzf.sock
# GET
curl --unix-socket /tmp/fzf.sock http
# POST
curl --unix-socket /tmp/fzf.sock http -d up
\fR
.TP
.BI "\-\-threads=" "N"
Number of matcher threads to use. The default value is
\fBmin(8 * NUM_CPU, 32)\fR.
.TP
.BI "\-\-bench=" "DURATION"
Repeatedly run \fB\-\-filter\fR for the given duration and print timing
statistics. Must be used with \fB\-\-filter\fR.
e.g.
\fBcat /usr/share/dict/words | fzf \-\-filter abc \-\-bench 10s\fR
.SS DIRECTORY TRAVERSAL
.TP
.B "\-\-walker=[file][,dir][,follow][,hidden]"
@@ -1327,6 +1456,8 @@ fzf exports the following environment variables to its child processes.
.br
.BR FZF_COLUMNS " Number of columns fzf takes up excluding padding and margin"
.br
.BR FZF_DIRECTION " Direction of the list (\fBup\fR or \fBdown\fR)"
.br
.BR FZF_TOTAL_COUNT " Total number of items"
.br
.BR FZF_MATCH_COUNT " Number of matched items"
@@ -1335,12 +1466,16 @@ fzf exports the following environment variables to its child processes.
.br
.BR FZF_POS " Vertical position of the cursor in the list starting from 1"
.br
.BR FZF_WRAP " The line wrapping mode (char, word) when enabled"
.br
.BR FZF_QUERY " Current query string"
.br
.BR FZF_INPUT_STATE " Current input state (enabled, disabled, hidden)"
.br
.BR FZF_NTH " Current \-\-nth option"
.br
.BR FZF_WITH_NTH " Current \-\-with\-nth option"
.br
.BR FZF_PROMPT " Prompt string"
.br
.BR FZF_GHOST " Ghost string"
@@ -1363,6 +1498,8 @@ fzf exports the following environment variables to its child processes.
.br
.BR FZF_PORT " Port number when \-\-listen option is used"
.br
.BR FZF_SOCK " Unix socket path when \-\-listen option is used"
.br
.BR FZF_PREVIEW_TOP " Top position of the preview window"
.br
.BR FZF_PREVIEW_LEFT " Left position of the preview window"
@@ -1370,6 +1507,8 @@ fzf exports the following environment variables to its child processes.
.BR FZF_PREVIEW_LINES " Number of lines in the preview window"
.br
.BR FZF_PREVIEW_COLUMNS " Number of columns in the preview window"
.br
.BR FZF_RAW " Only in raw mode. 1 if the current item matches, 0 otherwise"
.SH EXTENDED SEARCH MODE
@@ -1453,7 +1592,7 @@ e.g.
.br
\fIctrl\-/\fR (\fIctrl\-_\fR)
.br
\fIctrl\-alt\-[a\-z]\fR
\fIctrl\-alt\-[a\-z]\fR (\fIctrl\-alt\-h\fR is \fIctrl\-alt\-backspace\fR on non-Windows)
.br
\fIalt\-[*]\fR (Any case-sensitive single character is allowed)
.br
@@ -1583,7 +1722,7 @@ e.g.
.br
\fIctrl\-alt\-end\fR
.br
\fIctrl\-alt\-backspace\fR (\fIctrl\-alt\-bspace\fR \fIctrl\-alt\-bs\fR)
\fIctrl\-alt\-backspace\fR (\fIctrl\-alt\-bspace\fR \fIctrl\-alt\-bs\fR) (\fIctrl\-alt\-h\fR (non-Windows))
.br
\fIctrl\-alt\-delete\fR
.br
@@ -1814,17 +1953,20 @@ A key or an event can be bound to one or more of the following actions.
\fBbecome(...)\fR (replace fzf process with the specified command; see below for the details)
\fBbeginning\-of\-line\fR \fIctrl\-a home\fR
\fBbell\fR (ring the terminal bell)
\fBbest\fR (move to the best match; same as \fBfirst\fR if raw mode is disabled)
\fBbg\-cancel\fR (cancel background transform processes)
\fBcancel\fR (clear query string if not empty, abort fzf otherwise)
\fBchange\-border\-label(...)\fR (change \fB\-\-border\-label\fR to the given string)
\fBchange\-ghost(...)\fR (change ghost text to the given string)
\fBchange\-header(...)\fR (change header to the given string; doesn't affect \fB\-\-header\-lines\fR)
\fBchange\-header\-lines(N)\fR (change the number of \fB\-\-header\-lines\fR)
\fBchange\-header\-label(...)\fR (change \fB\-\-header\-label\fR to the given string)
\fBchange\-input\-label(...)\fR (change \fB\-\-input\-label\fR to the given string)
\fBchange\-list\-label(...)\fR (change \fB\-\-list\-label\fR to the given string)
\fBchange\-multi\fR (enable multi-select mode with no limit)
\fBchange\-multi(...)\fR (enable multi-select mode with a limit or disable it with 0)
\fBchange\-nth(...)\fR (change \fB\-\-nth\fR option; rotate through the multiple options separated by '|')
\fBchange\-with\-nth(...)\fR (change \fB\-\-with\-nth\fR option; rotate through the multiple options separated by '|')
\fBchange\-pointer(...)\fR (change \fB\-\-pointer\fR option)
\fBchange\-preview(...)\fR (change \fB\-\-preview\fR option)
\fBchange\-preview\-label(...)\fR (change \fB\-\-preview\-label\fR to the given string)
@@ -1838,9 +1980,13 @@ A key or an event can be bound to one or more of the following actions.
\fBdelete\-char\fR \fIdel\fR
\fBdelete\-char/eof\fR \fIctrl\-d\fR (same as \fBdelete\-char\fR except aborts fzf if query is empty)
\fBdeselect\fR
\fBdeselect\-all\fR (deselect all matches; to also clear non-matched selections, use \fBclear\-multi\fR)
\fBdeselect\-all\fR (deselect all matches; to also clear non-matching selections, use \fBclear\-multi\fR)
\fBdisable\-raw\fR (disable raw mode)
\fBdisable\-search\fR (disable search functionality)
\fBdown\fR \fIctrl\-j ctrl\-n down\fR
\fBdown\fR \fIctrl\-j down\fR
\fBdown\-match\fR \fIctrl\-n\fR \fIalt\-down\fR (move to the match below the cursor)
\fBdown\-selected\fR (move to the selected item below the cursor)
\fBenable\-raw\fR (enable raw mode)
\fBenable\-search\fR (enable search functionality)
\fBend\-of\-line\fR \fIctrl\-e end\fR
\fBexclude\fR (exclude the current item from the result)
@@ -1858,7 +2004,7 @@ A key or an event can be bound to one or more of the following actions.
\fBkill\-word\fR \fIalt\-d\fR
\fBlast\fR (move to the last match; same as \fBpos(\-1)\fR)
\fBnext\-history\fR (\fIctrl\-n\fR on \fB\-\-history\fR)
\fBnext\-selected\fR (move to the next selected item)
\fBnext\-selected\fR (synonym to \fBdown\-selected\fR)
\fBpage\-down\fR \fIpgdn\fR
\fBpage\-up\fR \fIpgup\fR
\fBhalf\-page\-down\fR
@@ -1871,7 +2017,7 @@ A key or an event can be bound to one or more of the following actions.
\fBoffset\-middle\fR (place the current item is in the middle of the screen)
\fBpos(...)\fR (move cursor to the numeric position; negative number to count from the end)
\fBprev\-history\fR (\fIctrl\-p\fR on \fB\-\-history\fR)
\fBprev\-selected\fR (move to the previous selected item)
\fBprev\-selected\fR (synonym to \fBup\-selected\fR)
\fBpreview(...)\fR (see below for the details)
\fBpreview\-down\fR \fIshift\-down\fR
\fBpreview\-up\fR \fIshift\-up\fR
@@ -1906,11 +2052,14 @@ A key or an event can be bound to one or more of the following actions.
\fBtoggle\-multi\-line\fR
\fBtoggle\-preview\fR
\fBtoggle\-preview\-wrap\fR
\fBtoggle\-preview\-wrap\-word\fR
\fBtoggle\-raw\fR (toggle raw mode for displaying non-matching items)
\fBtoggle\-search\fR (toggle search functionality)
\fBtoggle\-sort\fR
\fBtoggle\-track\fR (toggle global tracking option (\fB\-\-track\fR))
\fBtoggle\-track\-current\fR (toggle tracking of the current item)
\fBtoggle\-wrap\fR \fIctrl\-/\fR \fIalt\-/\fR
\fBtoggle\-wrap\fR
\fBtoggle\-wrap\-word\fR \fIctrl\-/\fR \fIalt\-/\fR
\fBtoggle+down\fR \fIctrl\-i (tab)\fR
\fBtoggle+up\fR \fIbtab (shift\-tab)\fR
\fBtrack\-current\fR (track the current item; automatically disabled if focus changes)
@@ -1918,10 +2067,12 @@ A key or an event can be bound to one or more of the following actions.
\fBtransform\-border\-label(...)\fR (transform border label using an external command)
\fBtransform\-ghost(...)\fR (transform ghost text using an external command)
\fBtransform\-header(...)\fR (transform header using an external command)
\fBtransform\-header\-lines(...)\fR (transform the number of \fB\-\-header\-lines\fR using an external command)
\fBtransform\-header\-label(...)\fR (transform header label using an external command)
\fBtransform\-input\-label(...)\fR (transform input label using an external command)
\fBtransform\-list\-label(...)\fR (transform list label using an external command)
\fBtransform\-nth(...)\fR (transform nth using an external command)
\fBtransform\-with\-nth(...)\fR (transform with-nth using an external command)
\fBtransform\-pointer(...)\fR (transform pointer using an external command)
\fBtransform\-preview\-label(...)\fR (transform preview label using an external command)
\fBtransform\-prompt(...)\fR (transform prompt string using an external command)
@@ -1932,7 +2083,9 @@ A key or an event can be bound to one or more of the following actions.
\fBunix\-line\-discard\fR \fIctrl\-u\fR
\fBunix\-word\-rubout\fR \fIctrl\-w\fR
\fBuntrack\-current\fR (stop tracking the current item; no-op if global tracking is enabled)
\fBup\fR \fIctrl\-k ctrl\-p 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\-selected\fR (move to the selected item above the cursor)
\fByank\fR \fIctrl\-y\fR
Each \fBtransform*\fR action has a corresponding \fBbg\-transform*\fR
@@ -2055,7 +2208,7 @@ payload of HTTP POST request to the \fB\-\-listen\fR server.
e.g.
\fB# Disallow selecting an empty line
echo \-e "1. Hello\\n2. Goodbye\\n\\n3. Exit" |
printf "1. Hello\\n2. Goodbye\\n\\n3. Exit" |
fzf \-\-height '~100%' \-\-reverse \-\-header 'Select one' \\
\-\-bind 'enter:transform:[[ \-n {} ]] &&
echo accept ||
+58 -26
View File
@@ -1,4 +1,4 @@
" Copyright (c) 2013-2025 Junegunn Choi
" Copyright (c) 2013-2026 Junegunn Choi
"
" MIT License
"
@@ -896,6 +896,7 @@ function! s:execute_term(dict, command, temps) abort
endif
endfunction
function! fzf.on_exit(id, code, ...)
silent! autocmd! fzf_popup_resize
if s:getpos() == self.ppos " {'window': 'enew'}
for [opt, val] in items(self.winopts)
execute 'let' opt '=' val
@@ -1023,15 +1024,17 @@ function! s:callback(dict, lines) abort
endfunction
if has('nvim')
function s:create_popup(opts) abort
function! s:create_popup() abort
let opts = s:popup_bounds()
let opts = extend({'relative': 'editor', 'style': 'minimal'}, opts)
let buf = nvim_create_buf(v:false, v:true)
let opts = extend({'relative': 'editor', 'style': 'minimal'}, a:opts)
let win = nvim_open_win(buf, v:true, opts)
call setwinvar(win, '&colorcolumn', '')
let s:popup_id = nvim_open_win(buf, v:true, opts)
call setwinvar(s:popup_id, '&colorcolumn', '')
" Colors
try
call setwinvar(win, '&winhighlight', 'Pmenu:,Normal:Normal')
call setwinvar(s:popup_id, '&winhighlight', 'Pmenu:,Normal:Normal')
let rules = get(g:, 'fzf_colors', {})
if has_key(rules, 'bg')
let color = call('s:get_color', rules.bg)
@@ -1039,40 +1042,61 @@ if has('nvim')
let ns = nvim_create_namespace('fzf_popup')
let hl = nvim_set_hl(ns, 'Normal',
\ &termguicolors ? { 'bg': color } : { 'ctermbg': str2nr(color) })
call nvim_win_set_hl_ns(win, ns)
call nvim_win_set_hl_ns(s:popup_id, ns)
endif
endif
catch
endtry
return buf
endfunction
function! s:resize_popup() abort
if !exists('s:popup_id') || !nvim_win_is_valid(s:popup_id)
return
endif
let opts = s:popup_bounds()
let opts = extend({'relative': 'editor'}, opts)
call nvim_win_set_config(s:popup_id, opts)
endfunction
else
function! s:create_popup(opts) abort
let s:popup_create = {buf -> popup_create(buf, #{
\ line: a:opts.row,
\ col: a:opts.col,
\ minwidth: a:opts.width,
\ maxwidth: a:opts.width,
\ minheight: a:opts.height,
\ maxheight: a:opts.height,
\ zindex: 1000,
\ })}
function! s:create_popup() abort
function! s:popup_create(buf)
let s:popup_id = popup_create(a:buf, #{zindex: 1000})
call s:resize_popup()
endfunction
autocmd TerminalOpen * ++once call s:popup_create(str2nr(expand('<abuf>')))
endfunction
function! s:resize_popup() abort
if !exists('s:popup_id') || empty(popup_getpos(s:popup_id))
return
endif
let opts = s:popup_bounds()
call popup_move(s:popup_id, {
\ 'line': opts.row,
\ 'col': opts.col,
\ 'minwidth': opts.width,
\ 'maxwidth': opts.width,
\ 'minheight': opts.height,
\ 'maxheight': opts.height,
\ })
endfunction
endif
function! s:popup(opts) abort
let xoffset = get(a:opts, 'xoffset', 0.5)
let yoffset = get(a:opts, 'yoffset', 0.5)
let relative = get(a:opts, 'relative', 0)
function! s:popup_bounds() abort
let opts = s:popup_opts
let xoffset = get(opts, 'xoffset', 0.5)
let yoffset = get(opts, 'yoffset', 0.5)
let relative = get(opts, 'relative', 0)
" Use current window size for positioning relatively positioned popups
let columns = relative ? winwidth(0) : &columns
let lines = relative ? winheight(0) : (&lines - has('nvim'))
" Size and position
let width = min([max([8, a:opts.width > 1 ? a:opts.width : float2nr(columns * a:opts.width)]), columns])
let height = min([max([4, a:opts.height > 1 ? a:opts.height : float2nr(lines * a:opts.height)]), lines])
let width = min([max([8, opts.width > 1 ? opts.width : float2nr(columns * opts.width)]), columns])
let height = min([max([4, opts.height > 1 ? opts.height : float2nr(lines * opts.height)]), lines])
let row = float2nr(yoffset * (lines - height)) + (relative ? win_screenpos(0)[0] - 1 : 0)
let col = float2nr(xoffset * (columns - width)) + (relative ? win_screenpos(0)[1] - 1 : 0)
@@ -1082,9 +1106,17 @@ function! s:popup(opts) abort
let row += !has('nvim')
let col += !has('nvim')
call s:create_popup({
\ 'row': row, 'col': col, 'width': width, 'height': height
\ })
return { 'row': row, 'col': col, 'width': width, 'height': height }
endfunction
function! s:popup(opts) abort
let s:popup_opts = a:opts
call s:create_popup()
augroup fzf_popup_resize
autocmd!
autocmd VimResized * call s:resize_popup()
augroup END
endfunction
let s:default_action = {
+7 -5
View File
@@ -1,10 +1,9 @@
__fzf_defaults() {
# $1: Prepend to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
# $2: Append to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
}
__fzf_exec_awk() {
@@ -22,12 +21,15 @@ __fzf_exec_awk() {
# modern point of view. To use a standard-conforming version in Solaris,
# one needs to explicitly use /usr/xpg4/bin/awk.
__fzf_awk=/usr/xpg4/bin/awk
elif command -v mawk >/dev/null 2>&1; then
elif command -v mawk > /dev/null 2>&1; then
# choose the faster mawk if: it's installed && build date >= 20230322 &&
# version >= 1.3.4
local n x y z d
IFS=' .' read -r n x y z d <<< $(command mawk -W version 2> /dev/null)
[[ $n == mawk ]] && (( d >= 20230302 && (x * 1000 + y) * 1000 + z >= 1003004 )) && __fzf_awk=mawk
[[ $n == mawk ]] &&
(((x * 1000 + y) * 1000 + z >= 1003004)) 2> /dev/null &&
((d >= 20230302)) 2> /dev/null &&
__fzf_awk=mawk
fi
fi
# Note: macOS awk has a quirk that it stops processing at all when it sees
+144 -91
View File
@@ -4,8 +4,6 @@
# / __/ / /_/ __/
# /_/ /___/_/ completion.bash
#
# - $FZF_TMUX (default: 0)
# - $FZF_TMUX_OPTS (default: empty)
# - $FZF_COMPLETION_TRIGGER (default: '**')
# - $FZF_COMPLETION_OPTS (default: empty)
# - $FZF_COMPLETION_PATH_OPTS (default: empty)
@@ -31,15 +29,16 @@ if [[ $- =~ i ]]; then
###########################################################
#----BEGIN shfmt
#----BEGIN INCLUDE common.sh
# NOTE: Do not directly edit this section, which is copied from "common.sh".
# To modify it, one can edit "common.sh" and run "./update-common.sh" to apply
# To modify it, one can edit "common.sh" and run "./update.sh" to apply
# the changes. See code comments in "common.sh" for the implementation details.
__fzf_defaults() {
printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
}
__fzf_exec_awk() {
@@ -47,10 +46,13 @@ __fzf_exec_awk() {
__fzf_awk=awk
if [[ $OSTYPE == solaris* && -x /usr/xpg4/bin/awk ]]; then
__fzf_awk=/usr/xpg4/bin/awk
elif command -v mawk >/dev/null 2>&1; then
elif command -v mawk > /dev/null 2>&1; then
local n x y z d
IFS=' .' read -r n x y z d <<< $(command mawk -W version 2> /dev/null)
[[ $n == mawk ]] && (( d >= 20230302 && (x * 1000 + y) * 1000 + z >= 1003004 )) && __fzf_awk=mawk
[[ $n == mawk ]] &&
(((x * 1000 + y) * 1000 + z >= 1003004)) 2> /dev/null &&
((d >= 20230302)) 2> /dev/null &&
__fzf_awk=mawk
fi
fi
LC_ALL=C exec "$__fzf_awk" "$@"
@@ -58,9 +60,9 @@ __fzf_exec_awk() {
#----END INCLUDE
__fzf_comprun() {
if [[ "$(type -t _fzf_comprun 2>&1)" = function ]]; then
if [[ "$(type -t _fzf_comprun 2>&1)" == function ]]; then
_fzf_comprun "$@"
elif [[ -n "${TMUX_PANE-}" ]] && { [[ "${FZF_TMUX:-0}" != 0 ]] || [[ -n "${FZF_TMUX_OPTS-}" ]]; }; then
elif [[ -n ${TMUX_PANE-} ]] && { [[ ${FZF_TMUX:-0} != 0 ]] || [[ -n ${FZF_TMUX_OPTS-} ]]; }; then
shift
fzf-tmux ${FZF_TMUX_OPTS:--d${FZF_TMUX_HEIGHT:-40%}} -- "$@"
else
@@ -72,13 +74,13 @@ __fzf_comprun() {
__fzf_orig_completion() {
local l comp f cmd
while read -r l; do
if [[ "$l" =~ ^(.*\ -F)\ *([^ ]*).*\ ([^ ]*)$ ]]; then
if [[ $l =~ ^(.*\ -F)\ *([^ ]*).*\ ([^ ]*)$ ]]; then
comp="${BASH_REMATCH[1]}"
f="${BASH_REMATCH[2]}"
cmd="${BASH_REMATCH[3]}"
[[ "$f" = _fzf_* ]] && continue
printf -v "_fzf_orig_completion_${cmd//[^A-Za-z0-9_]/_}" "%s" "${comp} %s ${cmd} #${f}"
if [[ "$l" = *" -o nospace "* ]] && [[ ! "${__fzf_nospace_commands-}" = *" $cmd "* ]]; then
[[ $f == _fzf_* ]] && continue
builtin printf -v "_fzf_orig_completion_${cmd//[^A-Za-z0-9_]/_}" "%s" "${comp} %s ${cmd} #${f}"
if [[ $l == *" -o nospace "* ]] && [[ ${__fzf_nospace_commands-} != *" $cmd "* ]]; then
__fzf_nospace_commands="${__fzf_nospace_commands-} $cmd "
fi
fi
@@ -107,19 +109,20 @@ __fzf_orig_completion_instantiate() {
orig="${!orig_var-}"
orig="${orig%#*}"
[[ $orig == *' %s '* ]] || return 1
printf -v REPLY "$orig" "$func"
builtin printf -v REPLY "$orig" "$func"
}
_fzf_opts_completion() {
local cur prev opts
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
prev="${COMP_WORDS[COMP_CWORD - 1]}"
opts="
+c --no-color
+i --no-ignore-case
+s --no-sort
+x --no-extended
--accept-nth
--ansi
--bash
--bind
@@ -133,56 +136,89 @@ _fzf_opts_completion() {
--expect
--filepath-word
--fish
--footer
--footer-border
--footer-label
--footer-label-pos
--freeze-left
--freeze-right
--gap
--gap-line
--ghost
--gutter
--gutter-raw
--header
--header-border
--header-first
--header-label
--header-label-pos
--header-lines
--header-lines-border
--height
--highlight-line
--history
--history-size
--hscroll-off
--id-nth
--info
--info-command
--input-border
--input-label
--input-label-pos
--jump-labels
--keep-right
--layout
--listen
--listen-unsafe
--list-border
--list-label
--list-label-pos
--literal
--man
--margin
--marker
--marker-multi-line
--min-height
--no-bold
--no-clear
--no-hscroll
--no-mouse
--no-input
--no-multi-line
--no-scrollbar
--no-separator
--no-unicode
--padding
--pointer
--preview
--preview-border
--preview-label
--preview-label-pos
--preview-window
--print-query
--print0
--prompt
--raw
--read0
--reverse
--scheme
--scroll-off
--scrollbar
--separator
--smart-case
--style
--sync
--tabstop
--tac
--tail
--tiebreak
--tmux
--track
--version
--walker
--walker-root
--walker-skip
--with-nth
--with-shell
--wrap
--wrap-sign
--preview-wrap-sign
--zsh
-0 --exit-0
-1 --select-1
@@ -197,32 +233,41 @@ _fzf_opts_completion() {
--"
case "${prev}" in
--scheme)
COMPREPLY=( $(compgen -W "default path history" -- "$cur") )
return 0
;;
--tiebreak)
COMPREPLY=( $(compgen -W "length chunk begin end index" -- "$cur") )
return 0
;;
--color)
COMPREPLY=( $(compgen -W "dark light 16 bw no" -- "$cur") )
return 0
;;
--layout)
COMPREPLY=( $(compgen -W "default reverse reverse-list" -- "$cur") )
return 0
;;
--info)
COMPREPLY=( $(compgen -W "default right hidden inline inline-right" -- "$cur") )
return 0
;;
--preview-window)
COMPREPLY=( $(compgen -W "
--scheme)
COMPREPLY=($(compgen -W "default path history" -- "$cur"))
return 0
;;
--tiebreak)
COMPREPLY=($(compgen -W "length chunk pathname begin end index" -- "$cur"))
return 0
;;
--color)
COMPREPLY=($(compgen -W "dark light base16 16 bw no" -- "$cur"))
return 0
;;
--layout)
COMPREPLY=($(compgen -W "default reverse reverse-list" -- "$cur"))
return 0
;;
--info)
COMPREPLY=($(compgen -W "default right hidden inline inline-right" -- "$cur"))
return 0
;;
--wrap)
COMPREPLY=($(compgen -W "char word" -- "$cur"))
return 0
;;
--style)
COMPREPLY=($(compgen -W "default minimal full" -- "$cur"))
return 0
;;
--preview-window)
COMPREPLY=($(compgen -W "
default
hidden
nohidden
wrap
wrap-word
nowrap
cycle
nocycle
@@ -231,6 +276,7 @@ _fzf_opts_completion() {
left
right
rounded border border-rounded
border-line
sharp border-sharp
border-bold
border-block
@@ -244,21 +290,23 @@ _fzf_opts_completion() {
border-left
border-right
follow
nofollow" -- "$cur") )
return 0
;;
--border)
COMPREPLY=( $(compgen -W "rounded sharp bold block thinblock double horizontal vertical top bottom left right none" -- "$cur") )
return 0
;;
--border-label-pos|--preview-label-pos)
COMPREPLY=( $(compgen -W "center bottom top" -- "$cur") )
return 0
;;
nofollow
info
noinfo" -- "$cur"))
return 0
;;
--border | --list-border | --header-border | --header-lines-border | --footer-border | --input-border | --preview-border)
COMPREPLY=($(compgen -W "line rounded sharp bold block thinblock double horizontal vertical top bottom left right none" -- "$cur"))
return 0
;;
--border-label-pos | --preview-label-pos | --list-label-pos | --header-label-pos | --footer-label-pos | --input-label-pos)
COMPREPLY=($(compgen -W "center bottom top" -- "$cur"))
return 0
;;
esac
if [[ "$cur" =~ ^-|\+ ]]; then
COMPREPLY=( $(compgen -W "${opts}" -- "$cur") )
if [[ $cur =~ ^-|\+ ]]; then
COMPREPLY=($(compgen -W "${opts}" -- "$cur"))
return 0
fi
@@ -272,7 +320,7 @@ _fzf_handle_dynamic_completion() {
orig_cmd="$1"
if __fzf_orig_completion_get_orig_func "$cmd"; then
"$REPLY" "$@"
elif [[ -n "${_fzf_completion_loader-}" ]]; then
elif [[ -n ${_fzf_completion_loader-} ]]; then
orig_complete=$(complete -p "$orig_cmd" 2> /dev/null)
$_fzf_completion_loader "$@"
ret=$?
@@ -286,7 +334,7 @@ _fzf_handle_dynamic_completion() {
__fzf_orig_completion_instantiate "$cmd" "${BASH_REMATCH[1]}" &&
orig_complete=$REPLY
if [[ "${__fzf_nospace_commands-}" = *" $orig_cmd "* ]]; then
if [[ ${__fzf_nospace_commands-} == *" $orig_cmd "* ]]; then
eval "${orig_complete/ -F / -o nospace -F }"
else
eval "$orig_complete"
@@ -306,50 +354,53 @@ __fzf_generic_path_completion() {
COMPREPLY=()
trigger=${FZF_COMPLETION_TRIGGER-'**'}
[[ $COMP_CWORD -ge 0 ]] && cur="${COMP_WORDS[COMP_CWORD]}"
if [[ "$cur" == *"$trigger" ]] && [[ $cur != *'$('* ]] && [[ $cur != *':='* ]] && [[ $cur != *'`'* ]]; then
if [[ $cur == *"$trigger" ]] && [[ $cur != *'$('* ]] && [[ $cur != *':='* ]] && [[ $cur != *'`'* ]]; then
base=${cur:0:${#cur}-${#trigger}}
eval "base=$base" 2> /dev/null || return
dir=
[[ $base = *"/"* ]] && dir="$base"
[[ $base == *"/"* ]] && dir="$base"
while true; do
if [[ -z "$dir" ]] || [[ -d "$dir" ]]; then
leftover=${base/#"$dir"}
leftover=${leftover/#\/}
[[ -z "$dir" ]] && dir='.'
[[ "$dir" != "/" ]] && dir="${dir/%\//}"
if [[ -z $dir ]] || [[ -d $dir ]]; then
leftover=${base/#"$dir"/}
leftover=${leftover/#\//}
[[ -z $dir ]] && dir='.'
[[ $dir != "/" ]] && dir="${dir/%\//}"
matches=$(
export FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --scheme=path" "${FZF_COMPLETION_OPTS-} $2")
unset FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS_FILE
if [[ $1 =~ dir ]]; then
eval "rest=(${FZF_COMPLETION_DIR_OPTS-})"
else
eval "rest=(${FZF_COMPLETION_PATH_OPTS-})"
fi
if declare -F "$1" > /dev/null; then
eval "$1 $(printf %q "$dir")" | __fzf_comprun "$4" -q "$leftover"
eval "$1 $(builtin printf %q "$dir")" | __fzf_comprun "$4" -q "$leftover" "${rest[@]}"
else
if [[ $1 =~ dir ]]; then
walker=dir,follow
eval "rest=(${FZF_COMPLETION_DIR_OPTS-})"
else
walker=file,dir,follow,hidden
eval "rest=(${FZF_COMPLETION_PATH_OPTS-})"
fi
__fzf_comprun "$4" -q "$leftover" --walker "$walker" --walker-root="$dir" "${rest[@]}"
fi | while read -r item; do
printf "%q " "${item%$3}$3"
builtin printf "%q " "${item%$3}$3"
done
)
matches=${matches% }
[[ -z "$3" ]] && [[ "${__fzf_nospace_commands-}" = *" ${COMP_WORDS[0]} "* ]] && matches="$matches "
if [[ -n "$matches" ]]; then
COMPREPLY=( "$matches" )
[[ -z $3 ]] && [[ ${__fzf_nospace_commands-} == *" ${COMP_WORDS[0]} "* ]] && matches="$matches "
if [[ -n $matches ]]; then
COMPREPLY=("$matches")
else
COMPREPLY=( "$cur" )
COMPREPLY=("$cur")
fi
# To redraw line after fzf closes (printf '\e[5n')
# To redraw line after fzf closes (builtin printf '\e[5n')
bind '"\e[0n": redraw-current-line' 2> /dev/null
printf '\e[5n'
builtin printf '\e[5n'
return 0
fi
dir=$(command dirname "$dir")
[[ "$dir" =~ /$ ]] || dir="$dir"/
[[ $dir =~ /$ ]] || dir="$dir"/
done
else
shift
@@ -365,15 +416,15 @@ _fzf_complete() {
args=("$@")
sep=
for i in "${!args[@]}"; do
if [[ "${args[$i]}" = -- ]]; then
if [[ ${args[$i]} == -- ]]; then
sep=$i
break
fi
done
if [[ -n "$sep" ]]; then
if [[ -n $sep ]]; then
str_arg=
rest=("${args[@]:$((sep + 1)):${#args[@]}}")
args=("${args[@]:0:$sep}")
args=("${args[@]:0:sep}")
else
str_arg=$1
args=()
@@ -388,21 +439,22 @@ _fzf_complete() {
trigger=${FZF_COMPLETION_TRIGGER-'**'}
cmd="${COMP_WORDS[0]}"
cur="${COMP_WORDS[COMP_CWORD]}"
if [[ "$cur" == *"$trigger" ]] && [[ $cur != *'$('* ]] && [[ $cur != *':='* ]] && [[ $cur != *'`'* ]]; then
if [[ $cur == *"$trigger" ]] && [[ $cur != *'$('* ]] && [[ $cur != *':='* ]] && [[ $cur != *'`'* ]]; then
cur=${cur:0:${#cur}-${#trigger}}
selected=$(
FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse" "${FZF_COMPLETION_OPTS-} $str_arg") \
FZF_DEFAULT_OPTS_FILE='' \
__fzf_comprun "${rest[0]}" "${args[@]}" -q "$cur" | eval "$post" | command tr '\n' ' ')
__fzf_comprun "${rest[0]}" "${args[@]}" -q "$cur" | eval "$post" | command tr '\n' ' '
)
selected=${selected% } # Strip trailing space not to repeat "-o nospace"
if [[ -n "$selected" ]]; then
if [[ -n $selected ]]; then
COMPREPLY=("$selected")
else
COMPREPLY=("$cur")
fi
bind '"\e[0n": redraw-current-line' 2> /dev/null
printf '\e[5n'
builtin printf '\e[5n'
return 0
else
_fzf_handle_dynamic_completion "$cmd" "${rest[@]}"
@@ -454,10 +506,10 @@ _fzf_proc_completion() {
'
_fzf_complete -m --header-lines=1 --no-preview --wrap --color fg:dim,nth:regular \
--bind "click-header:transform:$transformer" -- "$@" < <(
command ps -eo user,pid,ppid,start,time,command 2> /dev/null ||
command ps -eo user,pid,ppid,time,args 2> /dev/null || # For BusyBox
command ps --everyone --full --windows # For cygwin
)
command ps -eo user,pid,ppid,start,time,command 2> /dev/null ||
command ps -eo user,pid,ppid,time,args 2> /dev/null || # For BusyBox
command ps --everyone --full --windows # For cygwin
)
}
_fzf_proc_completion_post() {
@@ -474,7 +526,7 @@ _fzf_proc_completion_post() {
# # Set the local attribute for any non-local variable that is set by _known_hosts_real()
# local COMPREPLY=()
# _known_hosts_real ''
# printf '%s\n' "${COMPREPLY[@]}" | command sort -u --version-sort
# builtin printf '%s\n' "${COMPREPLY[@]}" | command sort -u --version-sort
# }
if ! declare -F __fzf_list_hosts > /dev/null; then
__fzf_list_hosts() {
@@ -540,12 +592,12 @@ _fzf_host_completion() {
# > and the third argument ($3) is the word preceding the word being completed on the current command line.
_fzf_complete_ssh() {
case $3 in
-i|-F|-E)
-i | -F | -E)
_fzf_path_completion "$@"
;;
*)
local user=
[[ "$2" =~ '@' ]] && user="${2%%@*}@"
[[ $2 =~ '@' ]] && user="${2%%@*}@"
_fzf_complete +m -- "$@" < <(__fzf_list_hosts | __fzf_exec_awk -v user="$user" '{print user $0}')
;;
esac
@@ -676,12 +728,13 @@ _fzf_setup_completion() {
__fzf_orig_completion < <(complete -p "$@" 2> /dev/null)
for cmd in "$@"; do
case "$kind" in
dir) __fzf_defc "$cmd" "$fn" "-o nospace -o dirnames" ;;
var) __fzf_defc "$cmd" "$fn" "-o default -o nospace -v" ;;
dir) __fzf_defc "$cmd" "$fn" "-o nospace -o dirnames" ;;
var) __fzf_defc "$cmd" "$fn" "-o default -o nospace -v" ;;
alias) __fzf_defc "$cmd" "$fn" "-a" ;;
*) __fzf_defc "$cmd" "$fn" "-o default -o bashdefault" ;;
*) __fzf_defc "$cmd" "$fn" "-o default -o bashdefault" ;;
esac
done
}
#----END shfmt
fi
+169
View File
@@ -0,0 +1,169 @@
# ____ ____
# / __/___ / __/
# / /_/_ / / /_
# / __/ / /_/ __/
# /_/ /___/_/ completion.fish
#
# - $FZF_COMPLETION_OPTS
# - $FZF_EXPANSION_OPTS
# The oldest supported fish version is 3.4.0. For this message being able to be
# displayed on older versions, the command substitution syntax $() should not
# be used anywhere in the script, otherwise the source command will fail.
if string match -qr -- '^[12]\\.|^3\\.[0-3]' $version
echo "fzf completion script requires fish version 3.4.0 or newer." >&2
return 1
else if not command -q fzf
echo "fzf was not found in path." >&2
return 1
end
function fzf_complete -w fzf -d 'fzf command completion and wildcard expansion search'
# Restore the default shift-tab behavior on tab completions
if commandline --paging-mode
commandline -f complete-and-search
return
end
# Remove any trailing unescaped backslash from token and update command line
set -l -- token (string replace -r -- '(?<!\\\\)(?:\\\\\\\\)*\\K\\\\$' '' (commandline -t | string collect) | string collect)
commandline -rt -- $token
# Remove any line breaks from token
set -- token (string replace -ra -- '\\\\\\n' '' $token | string collect)
# regex: Match token with unescaped/unquoted glob character
set -l -- r_glob '^(?:[^\'"\\\\*]|\\\\[\\S\\s]|\'(?:\\\\[\\S\\s]|[^\'\\\\])*\'|"(?:\\\\[\\S\\s]|[^"\\\\])*")*\\*[\\S\\s]*$'
# regex: Match any unbalanced quote character
set -l -- r_quote '^(?>(?:\\\\[\\s\\S]|"(?:[^"\\\\]|\\\\[\\s\\S])*"|\'(?:[^\'\\\\]|\\\\[\\s\\S])*\'|[^\'"\\\\]+)*)\\K[\'"]'
# The expansion pattern is the token with any open quote closed, or is empty.
set -l -- glob_pattern (string match -r -- $r_glob $token | string collect)(string match -r -- $r_quote $token | string collect -a)
set -l -- cl_tokenize_opt '--tokens-expanded'
string match -q -- '3.*' $version
and set -- cl_tokenize_opt '--tokenize'
# Set command line tokens without any leading variable definitions or launcher
# commands (including their options, but not any option arguments).
set -l -- r_cmd '^(?:(?:builtin|command|doas|env|sudo|\\w+=\\S*|-\\S+)\\s+)*\\K[\\s\\S]+'
set -l -- cmd (commandline $cl_tokenize_opt --input=(commandline -pc | string match -r $r_cmd))
test -z "$token"
and set -a -- cmd ''
# Set fzf options
test -z "$FZF_TMUX_HEIGHT"
and set -l -- FZF_TMUX_HEIGHT 40%
set -lax -- FZF_DEFAULT_OPTS \
"--height=$FZF_TMUX_HEIGHT --min-height=20+ --bind=ctrl-z:ignore" \
(test -r "$FZF_DEFAULT_OPTS_FILE"; and string join -- ' ' <$FZF_DEFAULT_OPTS_FILE) \
$FZF_DEFAULT_OPTS '--bind=alt-r:toggle-raw --multi --wrap=word --reverse' \
(if test -n "$glob_pattern"; string collect -- $FZF_EXPANSION_OPTS; else;
string collect -- $FZF_COMPLETION_OPTS; end; string escape -n -- $argv) \
--with-shell=(status fish-path)\\ -c
set -lx FZF_DEFAULT_OPTS_FILE
set -l -- fzf_cmd fzf
test "$FZF_TMUX" = 1
and set -- fzf_cmd fzf-tmux $FZF_TMUX_OPTS -d$FZF_TMUX_HEIGHT --
set -l result
# Get the completion list from stdin when it's not a tty
if not isatty stdin
set -l -- custom_post_func _fzf_post_complete_$cmd[1]
functions -q $custom_post_func
or set -- custom_post_func _fzf_complete_$cmd[1]_post
if functions -q $custom_post_func
$fzf_cmd | $custom_post_func $cmd | while read -l r; set -a -- result $r; end
else if string match -q -- '*--print0*' "$FZF_DEFAULT_OPTS"
$fzf_cmd | while read -lz r; set -a -- result $r; end
else
$fzf_cmd | while read -l r; set -a -- result $r; end
end
# Wildcard expansion
else if test -n "$glob_pattern"
# Set the command to be run by fzf, so there is a visual indicator and an
# easy way to abort on long recursive searches.
set -lx -- FZF_DEFAULT_COMMAND "for i in $glob_pattern;" \
'test -d "$i"; and string match -qv -- "*/" $i; and set -- i $i/;' \
'string join0 -- $i; end'
set -- result (string escape -n -- ($fzf_cmd --read0 --print0 --scheme=path --no-multi-line | string split0))
# Command completion
else
# Call custom function if defined
set -l -- custom_func _fzf_complete_$cmd[1]
if functions -q $custom_func; and not set -q __fzf_no_custom_complete
set -lx __fzf_no_custom_complete
$custom_func $cmd
return
end
# Workaround for complete not having newlines in results
if string match -qr -- '\\n' $token
set -- token (string replace -ra -- '(?<!\\\\)(?:\\\\\\\\)*\\K\\\\\$' '\\\\\\\\\$' $token | string collect)
set -- token (string unescape -- $token | string collect)
set -- token (string replace -ra -- '\\n' '\\\\n' $token | string collect)
end
set -- list (complete -C --escape -- (string join -- ' ' (commandline -pc $cl_tokenize_opt) $token | string collect))
if test -n "$list"
# Get the initial tabstop value
if set -l -- tabstop (string match -rga -- '--tabstop[= ](?:0*)([1-9]\\d+|[4-9])' "$FZF_DEFAULT_OPTS")[-1]
set -- tabstop (math $tabstop - 4)
else
set -- tabstop 4
end
# Determine the tabstop length for description alignment
set -l -- max_columns (math $COLUMNS - 40)
for i in $list[1..500]
set -l -- item (string split -f 1 -- \t $i)
and set -l -- len (string length -V -- $item)
and test "$len" -gt "$tabstop" -a "$len" -lt "$max_columns"
and set -- tabstop $len
end
set -- tabstop (math $tabstop + 4)
set -- result (string collect -- $list | $fzf_cmd --delimiter="\t" --tabstop=$tabstop --wrap-sign=\t"↳ " --accept-nth=1)
end
end
# Update command line
if test -n "$result"
# No extra space after single selection that ends with path separator
set -l -- tail ' '
test (count $result) -eq 1
and string match -q -- '*/' "$result"
and set -- tail ''
commandline -rt -- (string join -- ' ' $result)$tail
end
commandline -f repaint
end
function _fzf_complete
set -l fzf_args
for i in $argv
string match -q -- '--' $i; and break
set -a -- fzf_args $i
end
fzf_complete $fzf_args
end
# Bind to shift-tab
if string match -qr -- '^\\d\\d+|^[4-9]' $version
bind shift-tab fzf_complete
bind -M insert shift-tab fzf_complete
else
bind -k btab fzf_complete
bind -M insert -k btab fzf_complete
end
+14 -10
View File
@@ -4,8 +4,6 @@
# / __/ / /_/ __/
# /_/ /___/_/ completion.zsh
#
# - $FZF_TMUX (default: 0)
# - $FZF_TMUX_OPTS (default: empty)
# - $FZF_COMPLETION_TRIGGER (default: '**')
# - $FZF_COMPLETION_OPTS (default: empty)
# - $FZF_COMPLETION_PATH_OPTS (default: empty)
@@ -98,13 +96,13 @@ if [[ -o interactive ]]; then
#----BEGIN INCLUDE common.sh
# NOTE: Do not directly edit this section, which is copied from "common.sh".
# To modify it, one can edit "common.sh" and run "./update-common.sh" to apply
# To modify it, one can edit "common.sh" and run "./update.sh" to apply
# the changes. See code comments in "common.sh" for the implementation details.
__fzf_defaults() {
printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
}
__fzf_exec_awk() {
@@ -112,10 +110,13 @@ __fzf_exec_awk() {
__fzf_awk=awk
if [[ $OSTYPE == solaris* && -x /usr/xpg4/bin/awk ]]; then
__fzf_awk=/usr/xpg4/bin/awk
elif command -v mawk >/dev/null 2>&1; then
elif command -v mawk > /dev/null 2>&1; then
local n x y z d
IFS=' .' read -r n x y z d <<< $(command mawk -W version 2> /dev/null)
[[ $n == mawk ]] && (( d >= 20230302 && (x * 1000 + y) * 1000 + z >= 1003004 )) && __fzf_awk=mawk
[[ $n == mawk ]] &&
(((x * 1000 + y) * 1000 + z >= 1003004)) 2> /dev/null &&
((d >= 20230302)) 2> /dev/null &&
__fzf_awk=mawk
fi
fi
LC_ALL=C exec "$__fzf_awk" "$@"
@@ -171,15 +172,18 @@ __fzf_generic_path_completion() {
export FZF_DEFAULT_OPTS
FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --scheme=path" "${FZF_COMPLETION_OPTS-}")
unset FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS_FILE
if [[ $compgen =~ dir ]]; then
rest=${FZF_COMPLETION_DIR_OPTS-}
else
rest=${FZF_COMPLETION_PATH_OPTS-}
fi
if declare -f "$compgen" > /dev/null; then
eval "$compgen $(printf %q "$dir")" | __fzf_comprun "$cmd_word" ${(Q)${(Z+n+)fzf_opts}} -q "$leftover"
eval "$compgen $(printf %q "$dir")" | __fzf_comprun "$cmd_word" ${(Q)${(Z+n+)fzf_opts}} -q "$leftover" ${(Q)${(Z+n+)rest}}
else
if [[ $compgen =~ dir ]]; then
walker=dir,follow
rest=${FZF_COMPLETION_DIR_OPTS-}
else
walker=file,dir,follow,hidden
rest=${FZF_COMPLETION_PATH_OPTS-}
fi
__fzf_comprun "$cmd_word" ${(Q)${(Z+n+)fzf_opts}} -q "$leftover" --walker "$walker" --walker-root="$dir" ${(Q)${(Z+n+)rest}} < /dev/tty
fi | while read -r item; do
+75 -38
View File
@@ -4,9 +4,9 @@
# / __/ / /_/ __/
# /_/ /___/_/ key-bindings.bash
#
# - $FZF_TMUX_OPTS
# - $FZF_CTRL_T_COMMAND
# - $FZF_CTRL_T_OPTS
# - $FZF_CTRL_R_COMMAND
# - $FZF_CTRL_R_OPTS
# - $FZF_ALT_C_COMMAND
# - $FZF_ALT_C_OPTS
@@ -17,15 +17,16 @@ if [[ $- =~ i ]]; then
# Key bindings
# ------------
#----BEGIN shfmt
#----BEGIN INCLUDE common.sh
# NOTE: Do not directly edit this section, which is copied from "common.sh".
# To modify it, one can edit "common.sh" and run "./update-common.sh" to apply
# To modify it, one can edit "common.sh" and run "./update.sh" to apply
# the changes. See code comments in "common.sh" for the implementation details.
__fzf_defaults() {
printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
}
__fzf_exec_awk() {
@@ -33,10 +34,13 @@ __fzf_exec_awk() {
__fzf_awk=awk
if [[ $OSTYPE == solaris* && -x /usr/xpg4/bin/awk ]]; then
__fzf_awk=/usr/xpg4/bin/awk
elif command -v mawk >/dev/null 2>&1; then
elif command -v mawk > /dev/null 2>&1; then
local n x y z d
IFS=' .' read -r n x y z d <<< $(command mawk -W version 2> /dev/null)
[[ $n == mawk ]] && (( d >= 20230302 && (x * 1000 + y) * 1000 + z >= 1003004 )) && __fzf_awk=mawk
[[ $n == mawk ]] &&
(((x * 1000 + y) * 1000 + z >= 1003004)) 2> /dev/null &&
((d >= 20230302)) 2> /dev/null &&
__fzf_awk=mawk
fi
fi
LC_ALL=C exec "$__fzf_awk" "$@"
@@ -45,46 +49,64 @@ __fzf_exec_awk() {
__fzf_select__() {
FZF_DEFAULT_COMMAND=${FZF_CTRL_T_COMMAND:-} \
FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --walker=file,dir,follow,hidden --scheme=path" "${FZF_CTRL_T_OPTS-} -m") \
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd) "$@" |
FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --walker=file,dir,follow,hidden --scheme=path" "${FZF_CTRL_T_OPTS-} -m") \
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd) "$@" |
while read -r item; do
printf '%q ' "$item" # escape special chars
printf '%q ' "$item" # escape special chars
done
}
__fzfcmd() {
[[ -n "${TMUX_PANE-}" ]] && { [[ "${FZF_TMUX:-0}" != 0 ]] || [[ -n "${FZF_TMUX_OPTS-}" ]]; } &&
[[ -n ${TMUX_PANE-} ]] && { [[ ${FZF_TMUX:-0} != 0 ]] || [[ -n ${FZF_TMUX_OPTS-} ]]; } &&
echo "fzf-tmux ${FZF_TMUX_OPTS:--d${FZF_TMUX_HEIGHT:-40%}} -- " || echo "fzf"
}
fzf-file-widget() {
local selected="$(__fzf_select__ "$@")"
READLINE_LINE="${READLINE_LINE:0:$READLINE_POINT}$selected${READLINE_LINE:$READLINE_POINT}"
READLINE_POINT=$(( READLINE_POINT + ${#selected} ))
READLINE_LINE="${READLINE_LINE:0:READLINE_POINT}$selected${READLINE_LINE:READLINE_POINT}"
READLINE_POINT=$((READLINE_POINT + ${#selected}))
}
__fzf_cd__() {
local dir
dir=$(
FZF_DEFAULT_COMMAND=${FZF_ALT_C_COMMAND:-} \
FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --walker=dir,follow,hidden --scheme=path" "${FZF_ALT_C_OPTS-} +m") \
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd)
FZF_DEFAULT_OPTS=$(__fzf_defaults "--reverse --walker=dir,follow,hidden --scheme=path" "${FZF_ALT_C_OPTS-} +m") \
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd)
) && printf 'builtin cd -- %q' "$(builtin unset CDPATH && builtin cd -- "$dir" && builtin pwd)"
}
__fzf_history_delete() {
[[ -s $1 ]] || return
local offsets
offsets=($(sort -rnu "$1"))
for offset in "${offsets[@]}"; do
builtin history -d "$offset"
done
if [[ ${#offsets[@]} -gt 0 ]] && shopt -q histappend; then
builtin history -w
fi
}
if command -v perl > /dev/null; then
__fzf_history__() {
local output script
local output script deletefile
deletefile=$(mktemp)
script='BEGIN { getc; $/ = "\n\t"; $HISTCOUNT = $ENV{last_hist} + 1 } s/^[ *]//; s/\n/\n\t/gm; print $HISTCOUNT - $. . "\t$_" if !$seen{$_}++'
output=$(
set +o pipefail
builtin fc -lnr -2147483648 |
last_hist=$(HISTTIMEFORMAT='' builtin history 1) command perl -n -l0 -e "$script" |
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort --wrap-sign '"$'\t'"↳ ' --highlight-line ${FZF_CTRL_R_OPTS-} +m --read0") \
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '"$'\t'"↳ ' --highlight-line --bind 'shift-delete:execute-silent(cat {+f1} >> \"$deletefile\")+exclude-multi' --multi ${FZF_CTRL_R_OPTS-} --read0") \
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd) --query "$READLINE_LINE"
) || return
)
__fzf_history_delete "$deletefile"
command rm -f "$deletefile"
[[ -n $output ]] || return
READLINE_LINE=$(command perl -pe 's/^\d*\t//' <<< "$output")
if [[ -z "$READLINE_POINT" ]]; then
if [[ -z $READLINE_POINT ]]; then
echo "$READLINE_LINE"
else
READLINE_POINT=0x7fffffff
@@ -92,8 +114,9 @@ if command -v perl > /dev/null; then
}
else # awk - fallback for POSIX systems
__fzf_history__() {
local output script
[[ $(HISTTIMEFORMAT='' builtin history 1) =~ [[:digit:]]+ ]] # how many history entries
local output script deletefile
deletefile=$(mktemp)
[[ $(HISTTIMEFORMAT='' builtin history 1) =~ [[:digit:]]+ ]] # how many history entries
script='function P(b) { ++n; sub(/^[ *]/, "", b); if (!seen[b]++) { printf "%d\t%s%c", '$((BASH_REMATCH + 1))' - n, b, 0 } }
NR==1 { b = substr($0, 2); next }
/^\t/ { P(b); b = substr($0, 2); next }
@@ -101,13 +124,16 @@ else # awk - fallback for POSIX systems
END { if (NR) P(b) }'
output=$(
set +o pipefail
builtin fc -lnr -2147483648 2> /dev/null | # ( $'\t '<lines>$'\n' )* ; <lines> ::= [^\n]* ( $'\n'<lines> )*
__fzf_exec_awk "$script" | # ( <counter>$'\t'<lines>$'\000' )*
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort --wrap-sign '"$'\t'"↳ ' --highlight-line ${FZF_CTRL_R_OPTS-} +m --read0") \
builtin fc -lnr -2147483648 2> /dev/null | # ( $'\t '<lines>$'\n' )* ; <lines> ::= [^\n]* ( $'\n'<lines> )*
__fzf_exec_awk "$script" | # ( <counter>$'\t'<lines>$'\000' )*
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '"$'\t'"↳ ' --highlight-line --bind 'shift-delete:execute-silent(cat {+f1} >> \"$deletefile\")+exclude-multi' --multi ${FZF_CTRL_R_OPTS-} --read0") \
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd) --query "$READLINE_LINE"
) || return
)
__fzf_history_delete "$deletefile"
command rm -f "$deletefile"
[[ -n $output ]] || return
READLINE_LINE=${output#*$'\t'}
if [[ -z "$READLINE_POINT" ]]; then
if [[ -z $READLINE_POINT ]]; then
echo "$READLINE_LINE"
else
READLINE_POINT=0x7fffffff
@@ -116,43 +142,54 @@ else # awk - fallback for POSIX systems
fi
# Required to refresh the prompt after fzf
bind -m emacs-standard '"\er": redraw-current-line'
bind -m emacs-standard '"\C-\e(": redraw-current-line'
bind -m vi-command '"\C-z": emacs-editing-mode'
bind -m vi-insert '"\C-z": emacs-editing-mode'
bind -m emacs-standard '"\C-z": vi-editing-mode'
if (( BASH_VERSINFO[0] < 4 )); then
if ((BASH_VERSINFO[0] < 4)); then
# CTRL-T - Paste the selected file path into the command line
if [[ "${FZF_CTRL_T_COMMAND-x}" != "" ]]; then
bind -m emacs-standard '"\C-t": " \C-b\C-k \C-u`__fzf_select__`\e\C-e\er\C-a\C-y\C-h\C-e\e \C-y\ey\C-x\C-x\C-f\C-y\ey\C-_"'
if [[ ${FZF_CTRL_T_COMMAND-x} != "" ]]; then
bind -m emacs-standard '"\C-t": " \C-b\C-k \C-u`__fzf_select__`\e\C-e\C-\e(\C-a\C-y\C-h\C-e\e \C-y\ey\C-x\C-x\C-f\C-y\ey\C-_"'
bind -m vi-command '"\C-t": "\C-z\C-t\C-z"'
bind -m vi-insert '"\C-t": "\C-z\C-t\C-z"'
fi
# CTRL-R - Paste the selected command from history into the command line
bind -m emacs-standard '"\C-r": "\C-e \C-u\C-y\ey\C-u`__fzf_history__`\e\C-e\er"'
bind -m vi-command '"\C-r": "\C-z\C-r\C-z"'
bind -m vi-insert '"\C-r": "\C-z\C-r\C-z"'
if [[ ${FZF_CTRL_R_COMMAND-x} != "" ]]; then
if [[ -n ${FZF_CTRL_R_COMMAND-} ]]; then
echo "warning: FZF_CTRL_R_COMMAND is set to a custom command, but custom commands are not yet supported for CTRL-R" >&2
fi
bind -m emacs-standard '"\C-r": "\C-e \C-u\C-y\ey\C-u`__fzf_history__`\e\C-e\C-\e("'
bind -m vi-command '"\C-r": "\C-z\C-r\C-z"'
bind -m vi-insert '"\C-r": "\C-z\C-r\C-z"'
fi
else
# CTRL-T - Paste the selected file path into the command line
if [[ "${FZF_CTRL_T_COMMAND-x}" != "" ]]; then
if [[ ${FZF_CTRL_T_COMMAND-x} != "" ]]; then
bind -m emacs-standard -x '"\C-t": fzf-file-widget'
bind -m vi-command -x '"\C-t": fzf-file-widget'
bind -m vi-insert -x '"\C-t": fzf-file-widget'
fi
# CTRL-R - Paste the selected command from history into the command line
bind -m emacs-standard -x '"\C-r": __fzf_history__'
bind -m vi-command -x '"\C-r": __fzf_history__'
bind -m vi-insert -x '"\C-r": __fzf_history__'
if [[ ${FZF_CTRL_R_COMMAND-x} != "" ]]; then
if [[ -n ${FZF_CTRL_R_COMMAND-} ]]; then
echo "warning: FZF_CTRL_R_COMMAND is set to a custom command, but custom commands are not yet supported for CTRL-R" >&2
fi
bind -m emacs-standard -x '"\C-r": __fzf_history__'
bind -m vi-command -x '"\C-r": __fzf_history__'
bind -m vi-insert -x '"\C-r": __fzf_history__'
fi
fi
# ALT-C - cd into the selected directory
if [[ "${FZF_ALT_C_COMMAND-x}" != "" ]]; then
bind -m emacs-standard '"\ec": " \C-b\C-k \C-u`__fzf_cd__`\e\C-e\er\C-m\C-y\C-h\e \C-y\ey\C-x\C-x\C-d\C-y\ey\C-_"'
if [[ ${FZF_ALT_C_COMMAND-x} != "" ]]; then
bind -m emacs-standard '"\ec": " \C-b\C-k \C-u`__fzf_cd__`\e\C-e\C-\e(\C-m\C-y\C-h\e \C-y\ey\C-x\C-x\C-d\C-y\ey\C-_"'
bind -m vi-command '"\ec": "\C-z\ec\C-z"'
bind -m vi-insert '"\ec": "\C-z\ec\C-z"'
fi
#----END shfmt
fi
+62 -73
View File
@@ -4,27 +4,24 @@
# / __/ / /_/ __/
# /_/ /___/_/ key-bindings.fish
#
# - $FZF_TMUX_OPTS
# - $FZF_CTRL_T_COMMAND
# - $FZF_CTRL_T_OPTS
# - $FZF_CTRL_R_COMMAND
# - $FZF_CTRL_R_OPTS
# - $FZF_ALT_C_COMMAND
# - $FZF_ALT_C_OPTS
# Key bindings
# ------------
# The oldest supported fish version is 3.1b1. To maintain compatibility, the
# command substitution syntax $(cmd) should never be used, even behind a version
# check, otherwise the source command will fail on fish versions older than 3.4.0.
function fzf_key_bindings
# Check fish version
set -l fish_ver (string match -r '^(\d+).(\d+)' $version 2> /dev/null; or echo 0\n0\n0)
if test \( "$fish_ver[2]" -lt 3 \) -o \( "$fish_ver[2]" -eq 3 -a "$fish_ver[3]" -lt 1 \)
echo "This script requires fish version 3.1b1 or newer." >&2
# The oldest supported fish version is 3.4.0. For this message being able to be
# displayed on older versions, the command substitution syntax $() should not
# be used anywhere in the script, otherwise the source command will fail.
if string match -qr -- '^[12]\\.|^3\\.[0-3]' $version
echo "fzf key bindings script requires fish version 3.4.0 or newer." >&2
return 1
else if not type -q fzf
else if not command -q fzf
echo "fzf was not found in path." >&2
return 1
end
@@ -36,7 +33,7 @@ function fzf_key_bindings
string join ' ' -- \
"--height $FZF_TMUX_HEIGHT --min-height=20+ --bind=ctrl-z:ignore" $argv[1] \
(test -r "$FZF_DEFAULT_OPTS_FILE"; and string join -- ' ' <$FZF_DEFAULT_OPTS_FILE) \
$FZF_DEFAULT_OPTS $argv[2..-1]
$FZF_DEFAULT_OPTS $argv[2..]
end
function __fzfcmd
@@ -55,38 +52,25 @@ function fzf_key_bindings
set -l prefix ''
set -l dir '.'
# Set variables containing the major and minor fish version numbers, using
# a method compatible with all supported fish versions.
set -l -- fish_major (string match -r -- '^\d+' $version)
set -l -- fish_minor (string match -r -- '^\d+\.(\d+)' $version)[2]
set -l -- match_regex '(?<fzf_query>[\\s\\S]*?(?=\\n?$)$)'
set -l -- prefix_regex '^-[^\\s=]+=|^-(?!-)\\S'
# fish v3.3.0 and newer: Don't use option prefix if " -- " is preceded.
set -l -- match_regex '(?<fzf_query>[\s\S]*?(?=\n?$)$)'
set -l -- prefix_regex '^-[^\s=]+=|^-(?!-)\S'
if test "$fish_major" -eq 3 -a "$fish_minor" -lt 3
or string match -q -v -- '* -- *' (string sub -l (commandline -Cp) -- (commandline -p))
set -- match_regex "(?<prefix>$prefix_regex)?$match_regex"
end
# Don't use option prefix if " -- " is preceded.
string match -qv -- '* -- *' (string sub -l (commandline -Cp) -- (commandline -p))
and set -- match_regex "(?<prefix>$prefix_regex)?$match_regex"
# Set $prefix and expanded $fzf_query with preserved trailing newlines.
if test "$fish_major" -ge 4
if string match -qr -- '^\\d\\d+|^[4-9]' $version
# fish v4.0.0 and newer
string match -q -r -- $match_regex (commandline --current-token --tokens-expanded | string collect -N)
else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
# fish v3.2.0 - v3.7.1 (last v3)
string match -q -r -- $match_regex (commandline --current-token --tokenize | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)' '')
else
# fish older than v3.2.0 (v3.1b1 - v3.1.2)
set -l -- cl_token (commandline --current-token --tokenize | string collect -N)
set -- prefix (string match -r -- $prefix_regex $cl_token)
set -- fzf_query (string replace -- "$prefix" '' $cl_token | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)|\\\n\\\n$' '')
string match -q -r -- $match_regex (commandline --current-token --tokenize | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^\\\\(?=~)|\\\\(?=\\$\\w)' '')
end
if test -n "$fzf_query"
# Normalize path in $fzf_query, set $dir to the longest existing directory.
if test \( "$fish_major" -ge 4 \) -o \( "$fish_major" -eq 3 -a "$fish_minor" -ge 5 \)
if string match -qr -- '^\\d\\d+|^4|^3\\.[5-9]' $version
# fish v3.5.0 and newer
set -- fzf_query (path normalize -- $fzf_query)
set -- dir $fzf_query
@@ -94,35 +78,22 @@ function fzf_key_bindings
set -- dir (path dirname $dir)
end
else
# fish older than v3.5.0 (v3.1b1 - v3.4.1)
if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
# fish v3.2.0 - v3.4.1
string match -q -r -- '(?<fzf_query>^[\s\S]*?(?=\n?$)$)' \
(string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $fzf_query | string collect -N)
else
# fish v3.1b1 - v3.1.2
set -- fzf_query (string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $fzf_query | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r '\\\n$' '')
end
string match -q -r -- '(?<fzf_query>^[\\s\\S]*?(?=\\n?$)$)' \
(string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\\n)$' '' $fzf_query | string collect -N)
set -- dir $fzf_query
while not test -d "$dir"
set -- dir (dirname -z -- "$dir" | string split0)
end
end
if not string match -q -- '.' $dir; or string match -q -r -- '^\./|^\.$' $fzf_query
if not string match -q -- '.' $dir; or string match -qr -- '^\\.(/|$)' $fzf_query
# Strip $dir from $fzf_query - preserve trailing newlines.
if test "$fish_major" -ge 4
if string match -qr -- '^\\d\\d+|^[4-9]' $version
# fish v4.0.0 and newer
string match -q -r -- '^'(string escape --style=regex -- $dir)'/?(?<fzf_query>[\s\S]*)' $fzf_query
else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
# fish v3.2.0 - v3.7.1 (last v3)
string match -q -r -- '^/?(?<fzf_query>[\s\S]*?(?=\n?$)$)' \
(string replace -- "$dir" '' $fzf_query | string collect -N)
string match -q -r -- '^'(string escape --style=regex -- $dir)'/?(?<fzf_query>[\\s\\S]*)' $fzf_query
else
# fish older than v3.2.0 (v3.1b1 - v3.1.2)
set -- fzf_query (string replace -- "$dir" '' $fzf_query | string collect -N)
eval set -- fzf_query (string escape -n -- $fzf_query | string replace -r -a '^/?|\\\n$' '')
string match -q -r -- '^/?(?<fzf_query>[\\s\\S]*?(?=\\n?$)$)' \
(string replace -- "$dir" '' $fzf_query | string collect -N)
end
end
end
@@ -139,13 +110,13 @@ function fzf_key_bindings
set -lx FZF_DEFAULT_OPTS (__fzf_defaults \
"--reverse --walker=file,dir,follow,hidden --scheme=path" \
"$FZF_CTRL_T_OPTS --multi --print0")
"--multi $FZF_CTRL_T_OPTS --print0")
set -lx FZF_DEFAULT_COMMAND "$FZF_CTRL_T_COMMAND"
set -lx FZF_DEFAULT_OPTS_FILE
set -l result (eval (__fzfcmd) --walker-root=$dir --query=$fzf_query | string split0)
and commandline -rt -- (string join -- ' ' $prefix(string escape -- $result))' '
and commandline -rt -- (string join -- ' ' $prefix(string escape -n -- $result))' '
commandline -f repaint
end
@@ -156,24 +127,34 @@ function fzf_key_bindings
set -l -- total_lines (count $command_line)
set -l -- fzf_query (string escape -- $command_line[$current_line])
set -lx FZF_DEFAULT_OPTS (__fzf_defaults '' \
'--nth=2..,.. --scheme=history --multi --wrap-sign="\t↳ "' \
'--bind=\'shift-delete:execute-silent(eval history delete --exact --case-sensitive -- (string escape -n -- {+} | string replace -r -a "^\d*\\\\\\t|(?<=\\\\\\n)\\\\\\t" ""))+reload(eval $FZF_DEFAULT_COMMAND)\'' \
"--bind=ctrl-r:toggle-sort --highlight-line $FZF_CTRL_R_OPTS" \
'--accept-nth=2.. --read0 --print0 --with-shell='(status fish-path)\\ -c)
set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults '' \
'--with-nth=2.. --nth=2..,.. --scheme=history --multi --no-multi-line' \
'--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-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=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)
# Add dynamic preview options if preview command isn't already set by user
if string match -qvr -- '--preview[= ]' "$FZF_DEFAULT_OPTS"
# Prepend the options to allow user overrides
set -p -- FZF_DEFAULT_OPTS \
'--bind="focus,multi,resize:bg-transform:if test \\"$FZF_COLUMNS\\" -gt 100 -a \\\\( \\"$FZF_SELECT_COUNT\\" -gt 0 -o \\\\( -z \\"$FZF_WRAP\\" -a (string join0 -- <{f3..} | string length) -gt (math $FZF_COLUMNS - (switch $FZF_WITH_NTH; case 2..; echo 13; case 1,3..; echo 25; case 3..; echo 1; end)) \\\\) -o (string split0 -- <{sf3..} | fish_indent | count) -gt 1 \\\\); echo show-preview; else; echo hide-preview; end"' \
'--preview="test \\"$FZF_SELECT_COUNT\\" -gt 0; and string split0 -- <{+sf3..} | fish_indent (string match -q -- 3.\\\\* $version; or echo -- --only-indent) --ansi; and echo -n \\\\n; string collect -- \\\\#\\\\ {1} (string split0 -- <{sf3..}) | fish_indent --ansi"' \
'--preview-window="right,50%,wrap-word,follow,info,hidden"'
end
set -lx FZF_DEFAULT_OPTS_FILE
set -lx FZF_DEFAULT_COMMAND
if type -q perl
set -a FZF_DEFAULT_OPTS '--tac'
set FZF_DEFAULT_COMMAND 'builtin history -z --reverse | command perl -0 -pe \'s/^/$.\t/g; s/\n/\n\t/gm\''
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 FZF_DEFAULT_COMMAND \
'set -l h (builtin history -z --reverse | string split0);' \
'for i in (seq (count $h) -1 1);' \
'string join0 -- $i\t(string replace -a -- \n \n\t $h[$i] | string collect);' \
'end'
set -a -- FZF_DEFAULT_COMMAND '--show-time="%F %a %T%t%s%t"'
end
# Merge history from other sessions before searching
@@ -181,11 +162,11 @@ function fzf_key_bindings
if set -l result (eval $FZF_DEFAULT_COMMAND \| (__fzfcmd) --query=$fzf_query | string split0)
if test "$total_lines" -eq 1
commandline -- (string replace -a -- \n\t \n $result)
commandline -- $result
else
set -l a (math $current_line - 1)
set -l b (math $current_line + 1)
commandline -- $command_line[1..$a] (string replace -a -- \n\t \n $result)
commandline -- $command_line[1..$a] $result
commandline -a -- '' $command_line[$b..-1]
end
end
@@ -214,8 +195,13 @@ function fzf_key_bindings
commandline -f repaint
end
bind \cr fzf-history-widget
bind -M insert \cr fzf-history-widget
if not set -q FZF_CTRL_R_COMMAND; or test -n "$FZF_CTRL_R_COMMAND"
if test -n "$FZF_CTRL_R_COMMAND"
echo "warning: FZF_CTRL_R_COMMAND is set to a custom command, but custom commands are not yet supported for CTRL-R" >&2
end
bind \cr fzf-history-widget
bind -M insert \cr fzf-history-widget
end
if not set -q FZF_CTRL_T_COMMAND; or test -n "$FZF_CTRL_T_COMMAND"
bind \ct fzf-file-widget
@@ -228,3 +214,6 @@ function fzf_key_bindings
end
end
# Run setup
fzf_key_bindings
+51 -16
View File
@@ -4,9 +4,9 @@
# / __/ / /_/ __/
# /_/ /___/_/ key-bindings.zsh
#
# - $FZF_TMUX_OPTS
# - $FZF_CTRL_T_COMMAND
# - $FZF_CTRL_T_OPTS
# - $FZF_CTRL_R_COMMAND
# - $FZF_CTRL_R_OPTS
# - $FZF_ALT_C_COMMAND
# - $FZF_ALT_C_OPTS
@@ -40,13 +40,13 @@ if [[ -o interactive ]]; then
#----BEGIN INCLUDE common.sh
# NOTE: Do not directly edit this section, which is copied from "common.sh".
# To modify it, one can edit "common.sh" and run "./update-common.sh" to apply
# To modify it, one can edit "common.sh" and run "./update.sh" to apply
# the changes. See code comments in "common.sh" for the implementation details.
__fzf_defaults() {
printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
}
__fzf_exec_awk() {
@@ -54,10 +54,13 @@ __fzf_exec_awk() {
__fzf_awk=awk
if [[ $OSTYPE == solaris* && -x /usr/xpg4/bin/awk ]]; then
__fzf_awk=/usr/xpg4/bin/awk
elif command -v mawk >/dev/null 2>&1; then
elif command -v mawk > /dev/null 2>&1; then
local n x y z d
IFS=' .' read -r n x y z d <<< $(command mawk -W version 2> /dev/null)
[[ $n == mawk ]] && (( d >= 20230302 && (x * 1000 + y) * 1000 + z >= 1003004 )) && __fzf_awk=mawk
[[ $n == mawk ]] &&
(((x * 1000 + y) * 1000 + z >= 1003004)) 2> /dev/null &&
((d >= 20230302)) 2> /dev/null &&
__fzf_awk=mawk
fi
fi
LC_ALL=C exec "$__fzf_awk" "$@"
@@ -124,25 +127,52 @@ fi
# CTRL-R - Paste the selected command from history into the command line
fzf-history-widget() {
local selected
setopt localoptions noglobsubst noposixbuiltins pipefail no_aliases noglob nobash_rematch 2> /dev/null
local selected extracted_with_perl=0
setopt localoptions noglobsubst noposixbuiltins pipefail no_aliases no_glob no_sh_glob no_ksharrays extendedglob 2> /dev/null
# Ensure the module is loaded if not already, and the required features, such
# 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.
if zmodload -F zsh/parameter p:{commands,history} 2>/dev/null && (( ${+commands[perl]} )); then
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; }' |
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort --wrap-sign '\t↳ ' --highlight-line ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} +m --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))"
extracted_with_perl=1
else
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 --wrap-sign '\t↳ ' --highlight-line ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} +m") \
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_FILE='' $(__fzfcmd))"
fi
local ret=$?
local -a cmds
# Avoid leaking auto assigned values when using backreferences '(#b)'
local -a mbegin mend match
if [ -n "$selected" ]; then
if [[ $(__fzf_exec_awk '{print $1; exit}' <<< "$selected") =~ ^[1-9][0-9]* ]]; then
zle vi-fetch-history -n $MATCH
# Heuristic to check if the selected value is from history or a custom query
if ((( extracted_with_perl )) && [[ $selected == <->$'\t'* ]]) ||
((( ! extracted_with_perl )) && [[ $selected == [[:blank:]]#<->( |\* )* ]]); then
# Split at newlines
for line in ${(ps:\n:)selected}; do
if (( extracted_with_perl )); then
if [[ $line == (#b)(<->)(#B)$'\t'* ]]; then
(( ${+history[${match[1]}]} )) && cmds+=("${history[${match[1]}]}")
fi
elif [[ $line == [[:blank:]]#(#b)(<->)(#B)( |\* )* ]]; then
# Avoid $history array: lags behind 'fc' on foreign commands (*)
# https://zsh.org/mla/users/2024/msg00692.html
# Push BUFFER onto stack; fetch and save history entry from BUFFER; restore
zle .push-line
zle vi-fetch-history -n ${match[1]}
(( ${#BUFFER} )) && cmds+=("${BUFFER}")
BUFFER=""
zle .get-line
fi
done
if (( ${#cmds[@]} )); then
# Join by newline after stripping trailing newlines from each command
BUFFER="${(pj:\n:)${(@)cmds%%$'\n'#}}"
CURSOR=${#BUFFER}
fi
else # selected is a custom query, not from history
LBUFFER="$selected"
fi
@@ -150,10 +180,15 @@ fzf-history-widget() {
zle reset-prompt
return $ret
}
zle -N fzf-history-widget
bindkey -M emacs '^R' fzf-history-widget
bindkey -M vicmd '^R' fzf-history-widget
bindkey -M viins '^R' fzf-history-widget
if [[ ${FZF_CTRL_R_COMMAND-x} != "" ]]; then
if [[ -n ${FZF_CTRL_R_COMMAND-} ]]; then
echo "warning: FZF_CTRL_R_COMMAND is set to a custom command, but custom commands are not yet supported for CTRL-R" >&2
fi
zle -N fzf-history-widget
bindkey -M emacs '^R' fzf-history-widget
bindkey -M vicmd '^R' fzf-history-widget
bindkey -M viins '^R' fzf-history-widget
fi
fi
} always {
-31
View File
@@ -1,31 +0,0 @@
#!/bin/sh
# This script applies the contents of "common.sh" to the other files.
set -e
# Go to the directory that contains this script
dir=${0%"${0##*/}"}
if [ -n "$dir" ]; then
cd "$dir"
fi
update() {
{
sed -n '1,/^#----BEGIN INCLUDE common\.sh/p' "$1"
cat <<EOF
# NOTE: Do not directly edit this section, which is copied from "common.sh".
# To modify it, one can edit "common.sh" and run "./update-common.sh" to apply
# the changes. See code comments in "common.sh" for the implementation details.
EOF
grep -v '^[[:blank:]]*#' common.sh # remove code comments in common.sh
sed -n '/^#----END INCLUDE/,$p' "$1"
} > "$1.part"
mv -f "$1.part" "$1"
}
update completion.bash
update completion.zsh
update key-bindings.bash
update key-bindings.zsh
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# This script applies the contents of "common.sh" to the other files.
set -e
dir=${0%"${0##*/}"}
update() {
{
sed -n '1,/^#----BEGIN INCLUDE common\.sh/p' "$1"
cat << EOF
# NOTE: Do not directly edit this section, which is copied from "common.sh".
# To modify it, one can edit "common.sh" and run "./update.sh" to apply
# the changes. See code comments in "common.sh" for the implementation details.
EOF
echo
grep -v '^[[:blank:]]*#' "$dir/common.sh" # remove code comments in common.sh
sed -n '/^#----END INCLUDE/,$p' "$1"
} > "$1.part"
mv -f "$1.part" "$1"
}
update "$dir/completion.bash"
update "$dir/completion.zsh"
update "$dir/key-bindings.bash"
update "$dir/key-bindings.zsh"
# Check if --check is in ARGV
check=0
rest=()
for arg in "$@"; do
case $arg in
--check) check=1 ;;
*) rest+=("$arg") ;;
esac
done
fmt() {
if ! grep -q "^#----BEGIN shfmt" "$1"; then
if [[ $check == 1 ]]; then
shfmt -d "$1"
return $?
else
shfmt -w "$1"
fi
else
{
sed -n '1,/^#----BEGIN shfmt/p' "$1" | sed '$d'
sed -n '/^#----BEGIN shfmt/,/^#----END shfmt/p' "$1" | shfmt --filename "$1"
sed -n '/^#----END shfmt/,$p' "$1" | sed '1d'
} > "$1.part"
if [[ $check == 1 ]]; then
diff -q "$1" "$1.part"
ret=$?
rm -f "$1.part"
return $ret
fi
mv -f "$1.part" "$1"
fi
}
for file in "${rest[@]}"; do
fmt "$file" || exit $?
done
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2013-2025 Junegunn Choi
Copyright (c) 2013-2026 Junegunn Choi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+158 -144
View File
@@ -30,153 +30,167 @@ func _() {
_ = x[actChangeBorderLabel-19]
_ = x[actChangeGhost-20]
_ = x[actChangeHeader-21]
_ = x[actChangeFooter-22]
_ = x[actChangeHeaderLabel-23]
_ = x[actChangeFooterLabel-24]
_ = x[actChangeInputLabel-25]
_ = x[actChangeListLabel-26]
_ = x[actChangeMulti-27]
_ = x[actChangeNth-28]
_ = x[actChangePointer-29]
_ = x[actChangePreview-30]
_ = x[actChangePreviewLabel-31]
_ = x[actChangePreviewWindow-32]
_ = x[actChangePrompt-33]
_ = x[actChangeQuery-34]
_ = x[actClearScreen-35]
_ = x[actClearQuery-36]
_ = x[actClearSelection-37]
_ = x[actClose-38]
_ = x[actDeleteChar-39]
_ = x[actDeleteCharEof-40]
_ = x[actEndOfLine-41]
_ = x[actFatal-42]
_ = x[actForwardChar-43]
_ = x[actForwardWord-44]
_ = x[actForwardSubWord-45]
_ = x[actKillLine-46]
_ = x[actKillWord-47]
_ = x[actKillSubWord-48]
_ = x[actUnixLineDiscard-49]
_ = x[actUnixWordRubout-50]
_ = x[actYank-51]
_ = x[actBackwardKillWord-52]
_ = x[actBackwardKillSubWord-53]
_ = x[actSelectAll-54]
_ = x[actDeselectAll-55]
_ = x[actToggle-56]
_ = x[actToggleSearch-57]
_ = x[actToggleAll-58]
_ = x[actToggleDown-59]
_ = x[actToggleUp-60]
_ = x[actToggleIn-61]
_ = x[actToggleOut-62]
_ = x[actToggleTrack-63]
_ = x[actToggleTrackCurrent-64]
_ = x[actToggleHeader-65]
_ = x[actToggleWrap-66]
_ = x[actToggleMultiLine-67]
_ = x[actToggleHscroll-68]
_ = x[actTrackCurrent-69]
_ = x[actToggleInput-70]
_ = x[actHideInput-71]
_ = x[actShowInput-72]
_ = x[actUntrackCurrent-73]
_ = x[actDown-74]
_ = x[actUp-75]
_ = x[actPageUp-76]
_ = x[actPageDown-77]
_ = x[actPosition-78]
_ = x[actHalfPageUp-79]
_ = x[actHalfPageDown-80]
_ = x[actOffsetUp-81]
_ = x[actOffsetDown-82]
_ = x[actOffsetMiddle-83]
_ = x[actJump-84]
_ = x[actJumpAccept-85]
_ = x[actPrintQuery-86]
_ = x[actRefreshPreview-87]
_ = x[actReplaceQuery-88]
_ = x[actToggleSort-89]
_ = x[actShowPreview-90]
_ = x[actHidePreview-91]
_ = x[actTogglePreview-92]
_ = x[actTogglePreviewWrap-93]
_ = x[actTransform-94]
_ = x[actTransformBorderLabel-95]
_ = x[actTransformGhost-96]
_ = x[actTransformHeader-97]
_ = x[actTransformFooter-98]
_ = x[actTransformHeaderLabel-99]
_ = x[actTransformFooterLabel-100]
_ = x[actTransformInputLabel-101]
_ = x[actTransformListLabel-102]
_ = x[actTransformNth-103]
_ = x[actTransformPointer-104]
_ = x[actTransformPreviewLabel-105]
_ = x[actTransformPrompt-106]
_ = x[actTransformQuery-107]
_ = x[actTransformSearch-108]
_ = x[actTrigger-109]
_ = x[actBgTransform-110]
_ = x[actBgTransformBorderLabel-111]
_ = x[actBgTransformGhost-112]
_ = x[actBgTransformHeader-113]
_ = x[actBgTransformFooter-114]
_ = x[actBgTransformHeaderLabel-115]
_ = x[actBgTransformFooterLabel-116]
_ = x[actBgTransformInputLabel-117]
_ = x[actBgTransformListLabel-118]
_ = x[actBgTransformNth-119]
_ = x[actBgTransformPointer-120]
_ = x[actBgTransformPreviewLabel-121]
_ = x[actBgTransformPrompt-122]
_ = x[actBgTransformQuery-123]
_ = x[actBgTransformSearch-124]
_ = x[actBgCancel-125]
_ = x[actSearch-126]
_ = x[actPreview-127]
_ = x[actPreviewTop-128]
_ = x[actPreviewBottom-129]
_ = x[actPreviewUp-130]
_ = x[actPreviewDown-131]
_ = x[actPreviewPageUp-132]
_ = x[actPreviewPageDown-133]
_ = x[actPreviewHalfPageUp-134]
_ = x[actPreviewHalfPageDown-135]
_ = x[actPrevHistory-136]
_ = x[actPrevSelected-137]
_ = x[actPrint-138]
_ = x[actPut-139]
_ = x[actNextHistory-140]
_ = x[actNextSelected-141]
_ = x[actExecute-142]
_ = x[actExecuteSilent-143]
_ = x[actExecuteMulti-144]
_ = x[actSigStop-145]
_ = x[actFirst-146]
_ = x[actLast-147]
_ = x[actReload-148]
_ = x[actReloadSync-149]
_ = x[actDisableSearch-150]
_ = x[actEnableSearch-151]
_ = x[actSelect-152]
_ = x[actDeselect-153]
_ = x[actUnbind-154]
_ = x[actRebind-155]
_ = x[actToggleBind-156]
_ = x[actBecome-157]
_ = x[actShowHeader-158]
_ = x[actHideHeader-159]
_ = x[actBell-160]
_ = x[actExclude-161]
_ = x[actExcludeMulti-162]
_ = x[actAsync-163]
_ = x[actChangeHeaderLines-22]
_ = x[actChangeFooter-23]
_ = x[actChangeHeaderLabel-24]
_ = x[actChangeFooterLabel-25]
_ = x[actChangeInputLabel-26]
_ = x[actChangeListLabel-27]
_ = x[actChangeMulti-28]
_ = x[actChangeNth-29]
_ = x[actChangeWithNth-30]
_ = x[actChangePointer-31]
_ = x[actChangePreview-32]
_ = x[actChangePreviewLabel-33]
_ = x[actChangePreviewWindow-34]
_ = x[actChangePrompt-35]
_ = x[actChangeQuery-36]
_ = x[actClearScreen-37]
_ = x[actClearQuery-38]
_ = x[actClearSelection-39]
_ = x[actClose-40]
_ = x[actDeleteChar-41]
_ = x[actDeleteCharEof-42]
_ = x[actEndOfLine-43]
_ = x[actFatal-44]
_ = x[actForwardChar-45]
_ = x[actForwardWord-46]
_ = x[actForwardSubWord-47]
_ = x[actKillLine-48]
_ = x[actKillWord-49]
_ = x[actKillSubWord-50]
_ = x[actUnixLineDiscard-51]
_ = x[actUnixWordRubout-52]
_ = x[actYank-53]
_ = x[actBackwardKillWord-54]
_ = x[actBackwardKillSubWord-55]
_ = x[actSelectAll-56]
_ = x[actDeselectAll-57]
_ = x[actToggle-58]
_ = x[actToggleSearch-59]
_ = x[actToggleAll-60]
_ = x[actToggleDown-61]
_ = x[actToggleUp-62]
_ = x[actToggleIn-63]
_ = x[actToggleOut-64]
_ = x[actToggleTrack-65]
_ = x[actToggleTrackCurrent-66]
_ = x[actToggleHeader-67]
_ = x[actToggleWrap-68]
_ = x[actToggleWrapWord-69]
_ = x[actToggleMultiLine-70]
_ = x[actToggleHscroll-71]
_ = x[actToggleRaw-72]
_ = x[actEnableRaw-73]
_ = x[actDisableRaw-74]
_ = x[actTrackCurrent-75]
_ = x[actToggleInput-76]
_ = x[actHideInput-77]
_ = x[actShowInput-78]
_ = x[actUntrackCurrent-79]
_ = x[actDown-80]
_ = x[actDownMatch-81]
_ = x[actUp-82]
_ = x[actUpMatch-83]
_ = x[actPageUp-84]
_ = x[actPageDown-85]
_ = x[actPosition-86]
_ = x[actHalfPageUp-87]
_ = x[actHalfPageDown-88]
_ = x[actOffsetUp-89]
_ = x[actOffsetDown-90]
_ = x[actOffsetMiddle-91]
_ = x[actJump-92]
_ = x[actJumpAccept-93]
_ = x[actPrintQuery-94]
_ = x[actRefreshPreview-95]
_ = x[actReplaceQuery-96]
_ = x[actToggleSort-97]
_ = x[actShowPreview-98]
_ = x[actHidePreview-99]
_ = x[actTogglePreview-100]
_ = x[actTogglePreviewWrap-101]
_ = x[actTogglePreviewWrapWord-102]
_ = x[actTransform-103]
_ = x[actTransformBorderLabel-104]
_ = x[actTransformGhost-105]
_ = x[actTransformHeader-106]
_ = x[actTransformHeaderLines-107]
_ = x[actTransformFooter-108]
_ = x[actTransformHeaderLabel-109]
_ = x[actTransformFooterLabel-110]
_ = x[actTransformInputLabel-111]
_ = x[actTransformListLabel-112]
_ = x[actTransformNth-113]
_ = x[actTransformWithNth-114]
_ = x[actTransformPointer-115]
_ = x[actTransformPreviewLabel-116]
_ = x[actTransformPrompt-117]
_ = x[actTransformQuery-118]
_ = x[actTransformSearch-119]
_ = x[actTrigger-120]
_ = x[actBgTransform-121]
_ = x[actBgTransformBorderLabel-122]
_ = x[actBgTransformGhost-123]
_ = x[actBgTransformHeader-124]
_ = x[actBgTransformHeaderLines-125]
_ = x[actBgTransformFooter-126]
_ = x[actBgTransformHeaderLabel-127]
_ = x[actBgTransformFooterLabel-128]
_ = x[actBgTransformInputLabel-129]
_ = x[actBgTransformListLabel-130]
_ = x[actBgTransformNth-131]
_ = x[actBgTransformWithNth-132]
_ = x[actBgTransformPointer-133]
_ = x[actBgTransformPreviewLabel-134]
_ = x[actBgTransformPrompt-135]
_ = x[actBgTransformQuery-136]
_ = x[actBgTransformSearch-137]
_ = x[actBgCancel-138]
_ = x[actSearch-139]
_ = x[actPreview-140]
_ = x[actPreviewTop-141]
_ = x[actPreviewBottom-142]
_ = x[actPreviewUp-143]
_ = x[actPreviewDown-144]
_ = x[actPreviewPageUp-145]
_ = x[actPreviewPageDown-146]
_ = x[actPreviewHalfPageUp-147]
_ = x[actPreviewHalfPageDown-148]
_ = x[actPrevHistory-149]
_ = x[actPrevSelected-150]
_ = x[actPrint-151]
_ = x[actPut-152]
_ = x[actNextHistory-153]
_ = x[actNextSelected-154]
_ = x[actExecute-155]
_ = x[actExecuteSilent-156]
_ = x[actExecuteMulti-157]
_ = x[actSigStop-158]
_ = x[actBest-159]
_ = x[actFirst-160]
_ = x[actLast-161]
_ = x[actReload-162]
_ = x[actReloadSync-163]
_ = x[actDisableSearch-164]
_ = x[actEnableSearch-165]
_ = x[actSelect-166]
_ = x[actDeselect-167]
_ = x[actUnbind-168]
_ = x[actRebind-169]
_ = x[actToggleBind-170]
_ = x[actBecome-171]
_ = x[actShowHeader-172]
_ = x[actHideHeader-173]
_ = x[actBell-174]
_ = x[actExclude-175]
_ = x[actExcludeMulti-176]
_ = x[actAsync-177]
}
const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleMultiLineactToggleHscrollactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactUpactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsync"
const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeHeaderLinesactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangeWithNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleWrapWordactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTogglePreviewWrapWordactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformHeaderLinesactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformWithNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformHeaderLinesactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformWithNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsync"
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, 331, 351, 371, 390, 408, 422, 434, 450, 466, 487, 509, 524, 538, 552, 565, 582, 590, 603, 619, 631, 639, 653, 667, 684, 695, 706, 720, 738, 755, 762, 781, 803, 815, 829, 838, 853, 865, 878, 889, 900, 912, 926, 947, 962, 975, 993, 1009, 1024, 1038, 1050, 1062, 1079, 1086, 1091, 1100, 1111, 1122, 1135, 1150, 1161, 1174, 1189, 1196, 1209, 1222, 1239, 1254, 1267, 1281, 1295, 1311, 1331, 1343, 1366, 1383, 1401, 1419, 1442, 1465, 1487, 1508, 1523, 1542, 1566, 1584, 1601, 1619, 1629, 1643, 1668, 1687, 1707, 1727, 1752, 1777, 1801, 1824, 1841, 1862, 1888, 1908, 1927, 1947, 1958, 1967, 1977, 1990, 2006, 2018, 2032, 2048, 2066, 2086, 2108, 2122, 2137, 2145, 2151, 2165, 2180, 2190, 2206, 2221, 2231, 2239, 2246, 2255, 2268, 2284, 2299, 2308, 2319, 2328, 2337, 2350, 2359, 2372, 2385, 2392, 2402, 2417, 2425}
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}
func (i actionType) String() string {
if i < 0 || i >= actionType(len(_actionType_index)-1) {
+99
View File
@@ -0,0 +1,99 @@
# SIMD byte search: `indexByteTwo` / `lastIndexByteTwo`
## What these functions do
`indexByteTwo(s []byte, b1, b2 byte) int` -- returns the index of the
**first** occurrence of `b1` or `b2` in `s`, or `-1`.
`lastIndexByteTwo(s []byte, b1, b2 byte) int` -- returns the index of the
**last** occurrence of `b1` or `b2` in `s`, or `-1`.
They are used by the fuzzy matching algorithm (`algo.go`) to skip ahead
during case-insensitive search. Instead of calling `bytes.IndexByte` twice
(once for lowercase, once for uppercase), a single SIMD pass finds both at
once.
## File layout
| File | Purpose |
| ------ | --------- |
| `indexbyte2_arm64.go` | Go declarations (`//go:noescape`) for ARM64 |
| `indexbyte2_arm64.s` | ARM64 NEON assembly (32-byte aligned blocks, syndrome extraction) |
| `indexbyte2_amd64.go` | Go declarations + AVX2 runtime detection for AMD64 |
| `indexbyte2_amd64.s` | AMD64 AVX2/SSE2 assembly with CPUID dispatch |
| `indexbyte2_other.go` | Pure Go fallback for all other architectures |
| `indexbyte2_test.go` | Unit tests, exhaustive tests, fuzz tests, and benchmarks |
## How the SIMD implementations work
**ARM64 (NEON):**
- Broadcasts both needle bytes into NEON registers (`VMOV`).
- Processes 32-byte aligned chunks. For each chunk, compares all bytes
against both needles (`VCMEQ`), ORs the results (`VORR`), and builds a
64-bit syndrome with 2 bits per byte.
- `indexByteTwo` uses `RBIT` + `CLZ` to find the lowest set bit (first match).
- `lastIndexByteTwo` scans backward and uses `CLZ` on the raw syndrome to
find the highest set bit (last match).
- Handles alignment and partial first/last blocks with bit masking.
- Adapted from Go's `internal/bytealg/indexbyte_arm64.s`.
**AMD64 (AVX2 with SSE2 fallback):**
- At init time, `cpuHasAVX2()` checks CPUID + XGETBV for AVX2 and OS YMM
support. The result is cached in `_useAVX2`.
- **AVX2 path** (inputs >= 32 bytes, when available):
- Broadcasts both needles via `VPBROADCASTB`.
- Processes 32-byte blocks: `VPCMPEQB` against both needles, `VPOR`, then
`VPMOVMSKB` to get a 32-bit mask.
- 5 instructions per loop iteration (vs 7 for SSE2) at 2x the throughput.
- `VZEROUPPER` before every return to avoid SSE/AVX transition penalties.
- **SSE2 fallback** (inputs < 32 bytes, or CPUs without AVX2):
- Broadcasts via `PUNPCKLBW` + `PSHUFL`.
- Processes 16-byte blocks: `PCMPEQB`, `POR`, `PMOVMSKB`.
- Small inputs (<16 bytes) are handled with page-boundary-safe loads.
- Both paths use `BSFL` (forward) / `BSRL` (reverse) for bit scanning.
- Adapted from Go's `internal/bytealg/indexbyte_amd64.s`.
**Fallback (other platforms):**
- `indexByteTwo` uses two `bytes.IndexByte` calls with scope-limiting
(search `b1` first, then limit the `b2` search to `s[:i1]`).
- `lastIndexByteTwo` uses a simple backward for loop.
## Running tests
```bash
# Unit + exhaustive tests
go test ./src/algo/ -run 'TestIndexByteTwo|TestLastIndexByteTwo' -v
# Fuzz tests (run for 10 seconds each)
go test ./src/algo/ -run '^$' -fuzz FuzzIndexByteTwo -fuzztime 10s
go test ./src/algo/ -run '^$' -fuzz FuzzLastIndexByteTwo -fuzztime 10s
# Cross-architecture: test amd64 on an arm64 Mac (via Rosetta)
GOARCH=amd64 go test ./src/algo/ -run 'TestIndexByteTwo|TestLastIndexByteTwo' -v
GOARCH=amd64 go test ./src/algo/ -run '^$' -fuzz FuzzIndexByteTwo -fuzztime 10s
GOARCH=amd64 go test ./src/algo/ -run '^$' -fuzz FuzzLastIndexByteTwo -fuzztime 10s
```
## Running micro-benchmarks
```bash
# All indexByteTwo / lastIndexByteTwo benchmarks
go test ./src/algo/ -bench 'IndexByteTwo' -benchmem
# Specific size
go test ./src/algo/ -bench 'IndexByteTwo_1000'
```
Each benchmark compares the SIMD `asm` implementation against reference
implementations (`2xIndexByte` using `bytes.IndexByte`, and a simple `loop`).
## Correctness verification
The assembly is verified by three layers of testing:
1. **Table-driven tests** -- known inputs with expected outputs.
2. **Exhaustive tests** -- all lengths 0256, every match position, no-match
cases, and both-bytes-present cases, compared against a simple loop
reference.
3. **Fuzz tests** -- randomized inputs via `testing.F`, compared against the
same loop reference.
+27 -29
View File
@@ -321,22 +321,15 @@ type Algo func(caseSensitive bool, normalize bool, forward bool, input *util.Cha
func trySkip(input *util.Chars, caseSensitive bool, b byte, from int) int {
byteArray := input.Bytes()[from:]
idx := bytes.IndexByte(byteArray, b)
if idx == 0 {
// Can't skip any further
return from
}
// We may need to search for the uppercase letter again. We don't have to
// consider normalization as we can be sure that this is an ASCII string.
// For case-insensitive search of a letter, search for both cases in one pass
if !caseSensitive && b >= 'a' && b <= 'z' {
if idx > 0 {
byteArray = byteArray[:idx]
}
uidx := bytes.IndexByte(byteArray, b-32)
if uidx >= 0 {
idx = uidx
idx := IndexByteTwo(byteArray, b, b-32)
if idx < 0 {
return -1
}
return from + idx
}
idx := bytes.IndexByte(byteArray, b)
if idx < 0 {
return -1
}
@@ -365,7 +358,7 @@ func asciiFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int
firstIdx, idx, lastIdx := 0, 0, 0
var b byte
for pidx := 0; pidx < len(pattern); pidx++ {
for pidx := range pattern {
b = byte(pattern[pidx])
idx = trySkip(input, caseSensitive, b, idx)
if idx < 0 {
@@ -380,14 +373,17 @@ func asciiFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int
}
// Find the last appearance of the last character of the pattern to limit the search scope
bu := b
if !caseSensitive && b >= 'a' && b <= 'z' {
bu = b - 32
}
scope := input.Bytes()[lastIdx:]
for offset := len(scope) - 1; offset > 0; offset-- {
if scope[offset] == b || scope[offset] == bu {
return firstIdx, lastIdx + offset + 1
if len(scope) > 1 {
tail := scope[1:]
var end int
if !caseSensitive && b >= 'a' && b <= 'z' {
end = lastIndexByteTwo(tail, b, b-32)
} else {
end = bytes.LastIndexByte(tail, b)
}
if end >= 0 {
return firstIdx, lastIdx + 1 + end + 1
}
}
return firstIdx, lastIdx + 1
@@ -445,7 +441,9 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
// Since O(nm) algorithm can be prohibitively expensive for large input,
// we fall back to the greedy algorithm.
if slab != nil && N*M > cap(slab.I16) {
// Also, we should not allow a very long pattern to avoid 16-bit integer
// overflow in the score matrix. 1000 is a safe limit.
if slab != nil && N*M > cap(slab.I16) || M > 1000 {
return FuzzyMatchV1(caseSensitive, normalize, forward, input, pattern, withPos, slab)
}
@@ -501,7 +499,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
if pidx < M {
F[pidx] = int32(off)
pidx++
pchar = pattern[util.Min(pidx, M-1)]
pchar = pattern[min(pidx, M-1)]
}
lastIdx = off
}
@@ -519,9 +517,9 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
inGap = false
} else {
if inGap {
H0[off] = util.Max16(prevH0+scoreGapExtension, 0)
H0[off] = max(prevH0+scoreGapExtension, 0)
} else {
H0[off] = util.Max16(prevH0+scoreGapStart, 0)
H0[off] = max(prevH0+scoreGapStart, 0)
}
C0[off] = 0
inGap = true
@@ -587,7 +585,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
if b >= bonusBoundary && b > fb {
consecutive = 1
} else {
b = util.Max16(b, util.Max16(bonusConsecutive, fb))
b = max(b, bonusConsecutive, fb)
}
}
if s1+b < s2 {
@@ -600,7 +598,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
Csub[off] = consecutive
inGap = s1 < s2
score := util.Max16(util.Max16(s1, s2), 0)
score := max(s1, s2, 0)
if pidx == M-1 && (forward && score > maxScore || !forward && score >= maxScore) {
maxScore, maxScorePos = score, col
}
@@ -684,7 +682,7 @@ func calculateScore(caseSensitive bool, normalize bool, text *util.Chars, patter
if bonus >= bonusBoundary && bonus > firstBonus {
firstBonus = bonus
}
bonus = util.Max16(util.Max16(bonus, firstBonus), bonusConsecutive)
bonus = max(bonus, firstBonus, bonusConsecutive)
}
if pidx == 0 {
score += int(bonus * bonusFirstCharMultiplier)
@@ -726,7 +724,7 @@ func FuzzyMatchV1(caseSensitive bool, normalize bool, forward bool, text *util.C
lenRunes := text.Length()
lenPattern := len(pattern)
for index := 0; index < lenRunes; index++ {
for index := range lenRunes {
char := text.Get(indexAt(index, lenRunes, forward))
// This is considerably faster than blindly applying strings.ToLower to the
// whole string
+1 -1
View File
@@ -86,7 +86,7 @@ func TestFuzzyMatch(t *testing.T) {
scoreGapStart*2+scoreGapExtension*2)
assertMatch(t, fn, true, forward, "FooBar Baz", "FooB", 0, 4,
scoreMatch*4+int(bonusBoundaryWhite)*bonusFirstCharMultiplier+int(bonusBoundaryWhite)*2+
util.Max(bonusCamel123, int(bonusBoundaryWhite)))
max(bonusCamel123, int(bonusBoundaryWhite)))
// Consecutive bonus updated
assertMatch(t, fn, true, forward, "foo-bar", "o-ba", 2, 6,
+24
View File
@@ -0,0 +1,24 @@
//go:build amd64
package algo
var _useAVX2 bool
func init() {
_useAVX2 = cpuHasAVX2()
}
//go:noescape
func cpuHasAVX2() bool
// indexByteTwo returns the index of the first occurrence of b1 or b2 in s,
// or -1 if neither is present. Uses AVX2 when available, SSE2 otherwise.
//
//go:noescape
func IndexByteTwo(s []byte, b1, b2 byte) int
// lastIndexByteTwo returns the index of the last occurrence of b1 or b2 in s,
// or -1 if neither is present. Uses AVX2 when available, SSE2 otherwise.
//
//go:noescape
func lastIndexByteTwo(s []byte, b1, b2 byte) int
+377
View File
@@ -0,0 +1,377 @@
#include "textflag.h"
// func cpuHasAVX2() bool
//
// Checks CPUID and XGETBV for AVX2 + OS YMM support.
TEXT ·cpuHasAVX2(SB),NOSPLIT,$0-1
MOVQ BX, R8 // save BX (callee-saved, clobbered by CPUID)
// Check max CPUID leaf >= 7
MOVL $0, AX
CPUID
CMPL AX, $7
JL cpuid_no
// Check OSXSAVE (CPUID.1:ECX bit 27)
MOVL $1, AX
CPUID
TESTL $(1<<27), CX
JZ cpuid_no
// Check AVX2 (CPUID.7.0:EBX bit 5)
MOVL $7, AX
MOVL $0, CX
CPUID
TESTL $(1<<5), BX
JZ cpuid_no
// Check OS YMM state support via XGETBV
MOVL $0, CX
BYTE $0x0F; BYTE $0x01; BYTE $0xD0 // XGETBV → EDX:EAX
ANDL $6, AX // bits 1 (XMM) and 2 (YMM)
CMPL AX, $6
JNE cpuid_no
MOVQ R8, BX // restore BX
MOVB $1, ret+0(FP)
RET
cpuid_no:
MOVQ R8, BX
MOVB $0, ret+0(FP)
RET
// func IndexByteTwo(s []byte, b1, b2 byte) int
//
// Returns the index of the first occurrence of b1 or b2 in s, or -1.
// Uses AVX2 (32 bytes/iter) when available, SSE2 (16 bytes/iter) otherwise.
TEXT ·IndexByteTwo(SB),NOSPLIT,$0-40
MOVQ s_base+0(FP), SI
MOVQ s_len+8(FP), BX
MOVBLZX b1+24(FP), AX
MOVBLZX b2+25(FP), CX
LEAQ ret+32(FP), R8
TESTQ BX, BX
JEQ fwd_failure
// Try AVX2 for inputs >= 32 bytes
CMPQ BX, $32
JLT fwd_sse2
CMPB ·_useAVX2(SB), $1
JNE fwd_sse2
// ====== AVX2 forward search ======
MOVD AX, X0
VPBROADCASTB X0, Y0 // Y0 = splat(b1)
MOVD CX, X1
VPBROADCASTB X1, Y1 // Y1 = splat(b2)
MOVQ SI, DI
LEAQ -32(SI)(BX*1), AX // AX = last valid 32-byte chunk
JMP fwd_avx2_entry
fwd_avx2_loop:
VMOVDQU (DI), Y2
VPCMPEQB Y0, Y2, Y3
VPCMPEQB Y1, Y2, Y4
VPOR Y3, Y4, Y3
VPMOVMSKB Y3, DX
BSFL DX, DX
JNZ fwd_avx2_success
ADDQ $32, DI
fwd_avx2_entry:
CMPQ DI, AX
JB fwd_avx2_loop
// Last 32-byte chunk (may overlap with previous)
MOVQ AX, DI
VMOVDQU (AX), Y2
VPCMPEQB Y0, Y2, Y3
VPCMPEQB Y1, Y2, Y4
VPOR Y3, Y4, Y3
VPMOVMSKB Y3, DX
BSFL DX, DX
JNZ fwd_avx2_success
MOVQ $-1, (R8)
VZEROUPPER
RET
fwd_avx2_success:
SUBQ SI, DI
ADDQ DX, DI
MOVQ DI, (R8)
VZEROUPPER
RET
// ====== SSE2 forward search (< 32 bytes or no AVX2) ======
fwd_sse2:
// Broadcast b1 into X0
MOVD AX, X0
PUNPCKLBW X0, X0
PUNPCKLBW X0, X0
PSHUFL $0, X0, X0
// Broadcast b2 into X4
MOVD CX, X4
PUNPCKLBW X4, X4
PUNPCKLBW X4, X4
PSHUFL $0, X4, X4
CMPQ BX, $16
JLT fwd_small
MOVQ SI, DI
LEAQ -16(SI)(BX*1), AX
JMP fwd_sseloopentry
fwd_sseloop:
MOVOU (DI), X1
MOVOU X1, X2
PCMPEQB X0, X1
PCMPEQB X4, X2
POR X2, X1
PMOVMSKB X1, DX
BSFL DX, DX
JNZ fwd_ssesuccess
ADDQ $16, DI
fwd_sseloopentry:
CMPQ DI, AX
JB fwd_sseloop
// Search the last 16-byte chunk (may overlap)
MOVQ AX, DI
MOVOU (AX), X1
MOVOU X1, X2
PCMPEQB X0, X1
PCMPEQB X4, X2
POR X2, X1
PMOVMSKB X1, DX
BSFL DX, DX
JNZ fwd_ssesuccess
fwd_failure:
MOVQ $-1, (R8)
RET
fwd_ssesuccess:
SUBQ SI, DI
ADDQ DX, DI
MOVQ DI, (R8)
RET
fwd_small:
// Check if loading 16 bytes from SI would cross a page boundary
LEAQ 16(SI), AX
TESTW $0xff0, AX
JEQ fwd_endofpage
MOVOU (SI), X1
MOVOU X1, X2
PCMPEQB X0, X1
PCMPEQB X4, X2
POR X2, X1
PMOVMSKB X1, DX
BSFL DX, DX
JZ fwd_failure
CMPL DX, BX
JAE fwd_failure
MOVQ DX, (R8)
RET
fwd_endofpage:
MOVOU -16(SI)(BX*1), X1
MOVOU X1, X2
PCMPEQB X0, X1
PCMPEQB X4, X2
POR X2, X1
PMOVMSKB X1, DX
MOVL BX, CX
SHLL CX, DX
SHRL $16, DX
BSFL DX, DX
JZ fwd_failure
MOVQ DX, (R8)
RET
// func lastIndexByteTwo(s []byte, b1, b2 byte) int
//
// Returns the index of the last occurrence of b1 or b2 in s, or -1.
// Uses AVX2 (32 bytes/iter) when available, SSE2 (16 bytes/iter) otherwise.
TEXT ·lastIndexByteTwo(SB),NOSPLIT,$0-40
MOVQ s_base+0(FP), SI
MOVQ s_len+8(FP), BX
MOVBLZX b1+24(FP), AX
MOVBLZX b2+25(FP), CX
LEAQ ret+32(FP), R8
TESTQ BX, BX
JEQ back_failure
// Try AVX2 for inputs >= 32 bytes
CMPQ BX, $32
JLT back_sse2
CMPB ·_useAVX2(SB), $1
JNE back_sse2
// ====== AVX2 backward search ======
MOVD AX, X0
VPBROADCASTB X0, Y0
MOVD CX, X1
VPBROADCASTB X1, Y1
// DI = start of last 32-byte chunk
LEAQ -32(SI)(BX*1), DI
back_avx2_loop:
CMPQ DI, SI
JBE back_avx2_first
VMOVDQU (DI), Y2
VPCMPEQB Y0, Y2, Y3
VPCMPEQB Y1, Y2, Y4
VPOR Y3, Y4, Y3
VPMOVMSKB Y3, DX
BSRL DX, DX
JNZ back_avx2_success
SUBQ $32, DI
JMP back_avx2_loop
back_avx2_first:
// First 32 bytes (DI <= SI, load from SI)
VMOVDQU (SI), Y2
VPCMPEQB Y0, Y2, Y3
VPCMPEQB Y1, Y2, Y4
VPOR Y3, Y4, Y3
VPMOVMSKB Y3, DX
BSRL DX, DX
JNZ back_avx2_firstsuccess
MOVQ $-1, (R8)
VZEROUPPER
RET
back_avx2_success:
SUBQ SI, DI
ADDQ DX, DI
MOVQ DI, (R8)
VZEROUPPER
RET
back_avx2_firstsuccess:
MOVQ DX, (R8)
VZEROUPPER
RET
// ====== SSE2 backward search (< 32 bytes or no AVX2) ======
back_sse2:
// Broadcast b1 into X0
MOVD AX, X0
PUNPCKLBW X0, X0
PUNPCKLBW X0, X0
PSHUFL $0, X0, X0
// Broadcast b2 into X4
MOVD CX, X4
PUNPCKLBW X4, X4
PUNPCKLBW X4, X4
PSHUFL $0, X4, X4
CMPQ BX, $16
JLT back_small
// DI = start of last 16-byte chunk
LEAQ -16(SI)(BX*1), DI
back_sseloop:
CMPQ DI, SI
JBE back_ssefirst
MOVOU (DI), X1
MOVOU X1, X2
PCMPEQB X0, X1
PCMPEQB X4, X2
POR X2, X1
PMOVMSKB X1, DX
BSRL DX, DX
JNZ back_ssesuccess
SUBQ $16, DI
JMP back_sseloop
back_ssefirst:
// First 16 bytes (DI <= SI, load from SI)
MOVOU (SI), X1
MOVOU X1, X2
PCMPEQB X0, X1
PCMPEQB X4, X2
POR X2, X1
PMOVMSKB X1, DX
BSRL DX, DX
JNZ back_ssefirstsuccess
back_failure:
MOVQ $-1, (R8)
RET
back_ssesuccess:
SUBQ SI, DI
ADDQ DX, DI
MOVQ DI, (R8)
RET
back_ssefirstsuccess:
// DX = byte offset from base
MOVQ DX, (R8)
RET
back_small:
// Check page boundary
LEAQ 16(SI), AX
TESTW $0xff0, AX
JEQ back_endofpage
MOVOU (SI), X1
MOVOU X1, X2
PCMPEQB X0, X1
PCMPEQB X4, X2
POR X2, X1
PMOVMSKB X1, DX
// Mask to first BX bytes: keep bits 0..BX-1
MOVL $1, AX
MOVL BX, CX
SHLL CX, AX
DECL AX
ANDL AX, DX
BSRL DX, DX
JZ back_failure
MOVQ DX, (R8)
RET
back_endofpage:
// Load 16 bytes ending at base+n
MOVOU -16(SI)(BX*1), X1
MOVOU X1, X2
PCMPEQB X0, X1
PCMPEQB X4, X2
POR X2, X1
PMOVMSKB X1, DX
// Bits correspond to bytes [base+n-16, base+n).
// We want original bytes [0, n), which are bits [16-n, 16).
// Mask: keep bits (16-n) through 15.
MOVL $16, CX
SUBL BX, CX
SHRL CX, DX
SHLL CX, DX
BSRL DX, DX
JZ back_failure
// DX is the bit position in the loaded chunk.
// Original byte index = DX - (16 - n) = DX + n - 16
ADDL BX, DX
SUBL $16, DX
MOVQ DX, (R8)
RET
+17
View File
@@ -0,0 +1,17 @@
//go:build arm64
package algo
// indexByteTwo returns the index of the first occurrence of b1 or b2 in s,
// or -1 if neither is present. Implemented in assembly using ARM64 NEON
// to search for both bytes in a single pass.
//
//go:noescape
func IndexByteTwo(s []byte, b1, b2 byte) int
// lastIndexByteTwo returns the index of the last occurrence of b1 or b2 in s,
// or -1 if neither is present. Implemented in assembly using ARM64 NEON,
// scanning backward.
//
//go:noescape
func lastIndexByteTwo(s []byte, b1, b2 byte) int
+249
View File
@@ -0,0 +1,249 @@
#include "textflag.h"
// func IndexByteTwo(s []byte, b1, b2 byte) int
//
// Returns the index of the first occurrence of b1 or b2 in s, or -1.
// Uses ARM64 NEON to search for both bytes in a single pass over the data.
// Adapted from Go's internal/bytealg/indexbyte_arm64.s (single-byte version).
TEXT ·IndexByteTwo(SB),NOSPLIT,$0-40
MOVD s_base+0(FP), R0
MOVD s_len+8(FP), R2
MOVBU b1+24(FP), R1
MOVBU b2+25(FP), R7
MOVD $ret+32(FP), R8
// Core algorithm:
// For each 32-byte chunk we calculate a 64-bit syndrome value,
// with two bits per byte. We compare against both b1 and b2,
// OR the results, then use the same syndrome extraction as
// Go's IndexByte.
CBZ R2, fail
MOVD R0, R11
// Magic constant 0x40100401 allows us to identify which lane matches.
// Each byte in the group of 4 gets a distinct bit: 1, 4, 16, 64.
MOVD $0x40100401, R5
VMOV R1, V0.B16 // V0 = splat(b1)
VMOV R7, V7.B16 // V7 = splat(b2)
// Work with aligned 32-byte chunks
BIC $0x1f, R0, R3
VMOV R5, V5.S4
ANDS $0x1f, R0, R9
AND $0x1f, R2, R10
BEQ loop
// Input string is not 32-byte aligned. Process the first
// aligned 32-byte block and mask off bytes before our start.
VLD1.P (R3), [V1.B16, V2.B16]
SUB $0x20, R9, R4
ADDS R4, R2, R2
// Compare against both needles
VCMEQ V0.B16, V1.B16, V3.B16 // b1 vs first 16 bytes
VCMEQ V7.B16, V1.B16, V8.B16 // b2 vs first 16 bytes
VORR V8.B16, V3.B16, V3.B16 // combine
VCMEQ V0.B16, V2.B16, V4.B16 // b1 vs second 16 bytes
VCMEQ V7.B16, V2.B16, V9.B16 // b2 vs second 16 bytes
VORR V9.B16, V4.B16, V4.B16 // combine
// Build syndrome
VAND V5.B16, V3.B16, V3.B16
VAND V5.B16, V4.B16, V4.B16
VADDP V4.B16, V3.B16, V6.B16
VADDP V6.B16, V6.B16, V6.B16
VMOV V6.D[0], R6
// Clear the irrelevant lower bits
LSL $1, R9, R4
LSR R4, R6, R6
LSL R4, R6, R6
// The first block can also be the last
BLS masklast
// Have we found something already?
CBNZ R6, tail
loop:
VLD1.P (R3), [V1.B16, V2.B16]
SUBS $0x20, R2, R2
// Compare against both needles, OR results
VCMEQ V0.B16, V1.B16, V3.B16
VCMEQ V7.B16, V1.B16, V8.B16
VORR V8.B16, V3.B16, V3.B16
VCMEQ V0.B16, V2.B16, V4.B16
VCMEQ V7.B16, V2.B16, V9.B16
VORR V9.B16, V4.B16, V4.B16
// If we're out of data we finish regardless of the result
BLS end
// Fast check: OR both halves and check for any match
VORR V4.B16, V3.B16, V6.B16
VADDP V6.D2, V6.D2, V6.D2
VMOV V6.D[0], R6
CBZ R6, loop
end:
// Found something or out of data, build full syndrome
VAND V5.B16, V3.B16, V3.B16
VAND V5.B16, V4.B16, V4.B16
VADDP V4.B16, V3.B16, V6.B16
VADDP V6.B16, V6.B16, V6.B16
VMOV V6.D[0], R6
// Only mask for the last block
BHS tail
masklast:
// Clear irrelevant upper bits
ADD R9, R10, R4
AND $0x1f, R4, R4
SUB $0x20, R4, R4
NEG R4<<1, R4
LSL R4, R6, R6
LSR R4, R6, R6
tail:
CBZ R6, fail
RBIT R6, R6
SUB $0x20, R3, R3
CLZ R6, R6
ADD R6>>1, R3, R0
SUB R11, R0, R0
MOVD R0, (R8)
RET
fail:
MOVD $-1, R0
MOVD R0, (R8)
RET
// func lastIndexByteTwo(s []byte, b1, b2 byte) int
//
// Returns the index of the last occurrence of b1 or b2 in s, or -1.
// Scans backward using ARM64 NEON.
TEXT ·lastIndexByteTwo(SB),NOSPLIT,$0-40
MOVD s_base+0(FP), R0
MOVD s_len+8(FP), R2
MOVBU b1+24(FP), R1
MOVBU b2+25(FP), R7
MOVD $ret+32(FP), R8
CBZ R2, lfail
MOVD R0, R11 // save base
ADD R0, R2, R12 // R12 = end = base + len
MOVD $0x40100401, R5
VMOV R1, V0.B16 // V0 = splat(b1)
VMOV R7, V7.B16 // V7 = splat(b2)
VMOV R5, V5.S4
// Align: find the aligned block containing the last byte
SUB $1, R12, R3
BIC $0x1f, R3, R3 // R3 = start of aligned block containing last byte
// --- Process tail block ---
VLD1 (R3), [V1.B16, V2.B16]
VCMEQ V0.B16, V1.B16, V3.B16
VCMEQ V7.B16, V1.B16, V8.B16
VORR V8.B16, V3.B16, V3.B16
VCMEQ V0.B16, V2.B16, V4.B16
VCMEQ V7.B16, V2.B16, V9.B16
VORR V9.B16, V4.B16, V4.B16
VAND V5.B16, V3.B16, V3.B16
VAND V5.B16, V4.B16, V4.B16
VADDP V4.B16, V3.B16, V6.B16
VADDP V6.B16, V6.B16, V6.B16
VMOV V6.D[0], R6
// Mask upper bits (bytes past end of slice)
// tail_bytes = end - R3 (1..32)
SUB R3, R12, R10 // R10 = tail_bytes
MOVD $64, R4
SUB R10<<1, R4, R4 // R4 = 64 - 2*tail_bytes
LSL R4, R6, R6
LSR R4, R6, R6
// Is this also the head block?
CMP R11, R3 // R3 - R11
BLO lmaskfirst // R3 < base: head+tail in same block
BEQ ltailonly // R3 == base: single aligned block
// R3 > base: more blocks before this one
CBNZ R6, llast
B lbacksetup
ltailonly:
// Single block, already masked upper bits
CBNZ R6, llast
B lfail
lmaskfirst:
// Mask lower bits (bytes before start of slice)
SUB R3, R11, R4 // R4 = base - R3
LSL $1, R4, R4
LSR R4, R6, R6
LSL R4, R6, R6
CBNZ R6, llast
B lfail
lbacksetup:
SUB $0x20, R3
lbackloop:
VLD1 (R3), [V1.B16, V2.B16]
VCMEQ V0.B16, V1.B16, V3.B16
VCMEQ V7.B16, V1.B16, V8.B16
VORR V8.B16, V3.B16, V3.B16
VCMEQ V0.B16, V2.B16, V4.B16
VCMEQ V7.B16, V2.B16, V9.B16
VORR V9.B16, V4.B16, V4.B16
// Quick check: any match in this block?
VORR V4.B16, V3.B16, V6.B16
VADDP V6.D2, V6.D2, V6.D2
VMOV V6.D[0], R6
// Is this a head block? (R3 < base)
CMP R11, R3
BLO lheadblock
// Full block (R3 >= base)
CBNZ R6, lbackfound
// More blocks?
BEQ lfail // R3 == base, no more
SUB $0x20, R3
B lbackloop
lbackfound:
// Build full syndrome
VAND V5.B16, V3.B16, V3.B16
VAND V5.B16, V4.B16, V4.B16
VADDP V4.B16, V3.B16, V6.B16
VADDP V6.B16, V6.B16, V6.B16
VMOV V6.D[0], R6
B llast
lheadblock:
// R3 < base. Build full syndrome if quick check had a match.
CBZ R6, lfail
VAND V5.B16, V3.B16, V3.B16
VAND V5.B16, V4.B16, V4.B16
VADDP V4.B16, V3.B16, V6.B16
VADDP V6.B16, V6.B16, V6.B16
VMOV V6.D[0], R6
// Mask lower bits
SUB R3, R11, R4 // R4 = base - R3
LSL $1, R4, R4
LSR R4, R6, R6
LSL R4, R6, R6
CBZ R6, lfail
llast:
// Find last match: highest set bit in syndrome
// Syndrome has bit 2i set for matching byte i.
// CLZ gives leading zeros; byte_offset = (63 - CLZ) / 2.
CLZ R6, R6
MOVD $63, R4
SUB R6, R4, R6 // R6 = 63 - CLZ = bit position
LSR $1, R6 // R6 = byte offset within block
ADD R3, R6, R0 // R0 = absolute address
SUB R11, R0, R0 // R0 = slice index
MOVD R0, (R8)
RET
lfail:
MOVD $-1, R0
MOVD R0, (R8)
RET
+33
View File
@@ -0,0 +1,33 @@
//go:build !arm64 && !amd64
package algo
import "bytes"
// indexByteTwo returns the index of the first occurrence of b1 or b2 in s,
// or -1 if neither is present.
func IndexByteTwo(s []byte, b1, b2 byte) int {
i1 := bytes.IndexByte(s, b1)
if i1 == 0 {
return 0
}
scope := s
if i1 > 0 {
scope = s[:i1]
}
if i2 := bytes.IndexByte(scope, b2); i2 >= 0 {
return i2
}
return i1
}
// lastIndexByteTwo returns the index of the last occurrence of b1 or b2 in s,
// or -1 if neither is present.
func lastIndexByteTwo(s []byte, b1, b2 byte) int {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == b1 || s[i] == b2 {
return i
}
}
return -1
}
+259
View File
@@ -0,0 +1,259 @@
package algo
import (
"bytes"
"testing"
)
func TestIndexByteTwo(t *testing.T) {
tests := []struct {
name string
s string
b1 byte
b2 byte
want int
}{
{"empty", "", 'a', 'b', -1},
{"single_b1", "a", 'a', 'b', 0},
{"single_b2", "b", 'a', 'b', 0},
{"single_none", "c", 'a', 'b', -1},
{"b1_first", "xaxb", 'a', 'b', 1},
{"b2_first", "xbxa", 'a', 'b', 1},
{"same_byte", "xxa", 'a', 'a', 2},
{"at_end", "xxxxa", 'a', 'b', 4},
{"not_found", "xxxxxxxx", 'a', 'b', -1},
{"long_b1_at_3000", string(make([]byte, 3000)) + "a" + string(make([]byte, 1000)), 'a', 'b', 3000},
{"long_b2_at_3000", string(make([]byte, 3000)) + "b" + string(make([]byte, 1000)), 'a', 'b', 3000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IndexByteTwo([]byte(tt.s), tt.b1, tt.b2)
if got != tt.want {
t.Errorf("IndexByteTwo(%q, %c, %c) = %d, want %d", tt.s[:min(len(tt.s), 40)], tt.b1, tt.b2, got, tt.want)
}
})
}
// Exhaustive test: compare against loop reference for various lengths,
// including sizes around SIMD block boundaries (16, 32, 64).
for n := 0; n <= 256; n++ {
data := make([]byte, n)
for i := range data {
data[i] = byte('c' + (i % 20))
}
// Test with match at every position
for pos := 0; pos < n; pos++ {
for _, b := range []byte{'A', 'B'} {
data[pos] = b
got := IndexByteTwo(data, 'A', 'B')
want := loopIndexByteTwo(data, 'A', 'B')
if got != want {
t.Fatalf("IndexByteTwo(len=%d, match=%c@%d) = %d, want %d", n, b, pos, got, want)
}
data[pos] = byte('c' + (pos % 20))
}
}
// Test with no match
got := IndexByteTwo(data, 'A', 'B')
if got != -1 {
t.Fatalf("IndexByteTwo(len=%d, no match) = %d, want -1", n, got)
}
// Test with both bytes present
if n >= 2 {
data[n/3] = 'A'
data[n*2/3] = 'B'
got := IndexByteTwo(data, 'A', 'B')
want := loopIndexByteTwo(data, 'A', 'B')
if got != want {
t.Fatalf("IndexByteTwo(len=%d, both@%d,%d) = %d, want %d", n, n/3, n*2/3, got, want)
}
data[n/3] = byte('c' + ((n / 3) % 20))
data[n*2/3] = byte('c' + ((n * 2 / 3) % 20))
}
}
}
func TestLastIndexByteTwo(t *testing.T) {
tests := []struct {
name string
s string
b1 byte
b2 byte
want int
}{
{"empty", "", 'a', 'b', -1},
{"single_b1", "a", 'a', 'b', 0},
{"single_b2", "b", 'a', 'b', 0},
{"single_none", "c", 'a', 'b', -1},
{"b1_last", "xbxa", 'a', 'b', 3},
{"b2_last", "xaxb", 'a', 'b', 3},
{"same_byte", "axx", 'a', 'a', 0},
{"at_start", "axxxx", 'a', 'b', 0},
{"both_present", "axbx", 'a', 'b', 2},
{"not_found", "xxxxxxxx", 'a', 'b', -1},
{"long_b1_at_3000", string(make([]byte, 3000)) + "a" + string(make([]byte, 1000)), 'a', 'b', 3000},
{"long_b2_at_end", string(make([]byte, 4000)) + "b", 'a', 'b', 4000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := lastIndexByteTwo([]byte(tt.s), tt.b1, tt.b2)
if got != tt.want {
t.Errorf("lastIndexByteTwo(%q, %c, %c) = %d, want %d", tt.s[:min(len(tt.s), 40)], tt.b1, tt.b2, got, tt.want)
}
})
}
// Exhaustive test against loop reference
for n := 0; n <= 256; n++ {
data := make([]byte, n)
for i := range data {
data[i] = byte('c' + (i % 20))
}
for pos := 0; pos < n; pos++ {
for _, b := range []byte{'A', 'B'} {
data[pos] = b
got := lastIndexByteTwo(data, 'A', 'B')
want := refLastIndexByteTwo(data, 'A', 'B')
if got != want {
t.Fatalf("lastIndexByteTwo(len=%d, match=%c@%d) = %d, want %d", n, b, pos, got, want)
}
data[pos] = byte('c' + (pos % 20))
}
}
// No match
got := lastIndexByteTwo(data, 'A', 'B')
if got != -1 {
t.Fatalf("lastIndexByteTwo(len=%d, no match) = %d, want -1", n, got)
}
// Both bytes present
if n >= 2 {
data[n/3] = 'A'
data[n*2/3] = 'B'
got := lastIndexByteTwo(data, 'A', 'B')
want := refLastIndexByteTwo(data, 'A', 'B')
if got != want {
t.Fatalf("lastIndexByteTwo(len=%d, both@%d,%d) = %d, want %d", n, n/3, n*2/3, got, want)
}
data[n/3] = byte('c' + ((n / 3) % 20))
data[n*2/3] = byte('c' + ((n * 2 / 3) % 20))
}
}
}
func FuzzIndexByteTwo(f *testing.F) {
f.Add([]byte("hello world"), byte('o'), byte('l'))
f.Add([]byte(""), byte('a'), byte('b'))
f.Add([]byte("aaa"), byte('a'), byte('a'))
f.Fuzz(func(t *testing.T, data []byte, b1, b2 byte) {
got := IndexByteTwo(data, b1, b2)
want := loopIndexByteTwo(data, b1, b2)
if got != want {
t.Errorf("IndexByteTwo(len=%d, b1=%d, b2=%d) = %d, want %d", len(data), b1, b2, got, want)
}
})
}
func FuzzLastIndexByteTwo(f *testing.F) {
f.Add([]byte("hello world"), byte('o'), byte('l'))
f.Add([]byte(""), byte('a'), byte('b'))
f.Add([]byte("aaa"), byte('a'), byte('a'))
f.Fuzz(func(t *testing.T, data []byte, b1, b2 byte) {
got := lastIndexByteTwo(data, b1, b2)
want := refLastIndexByteTwo(data, b1, b2)
if got != want {
t.Errorf("lastIndexByteTwo(len=%d, b1=%d, b2=%d) = %d, want %d", len(data), b1, b2, got, want)
}
})
}
// Reference implementations for correctness checking
func refIndexByteTwo(s []byte, b1, b2 byte) int {
i1 := bytes.IndexByte(s, b1)
if i1 == 0 {
return 0
}
scope := s
if i1 > 0 {
scope = s[:i1]
}
if i2 := bytes.IndexByte(scope, b2); i2 >= 0 {
return i2
}
return i1
}
func loopIndexByteTwo(s []byte, b1, b2 byte) int {
for i, b := range s {
if b == b1 || b == b2 {
return i
}
}
return -1
}
func refLastIndexByteTwo(s []byte, b1, b2 byte) int {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == b1 || s[i] == b2 {
return i
}
}
return -1
}
func benchIndexByteTwo(b *testing.B, size int, pos int) {
data := make([]byte, size)
for i := range data {
data[i] = byte('a' + (i % 20))
}
data[pos] = 'Z'
type impl struct {
name string
fn func([]byte, byte, byte) int
}
impls := []impl{
{"asm", IndexByteTwo},
{"2xIndexByte", refIndexByteTwo},
{"loop", loopIndexByteTwo},
}
for _, im := range impls {
b.Run(im.name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
im.fn(data, 'Z', 'z')
}
})
}
}
func benchLastIndexByteTwo(b *testing.B, size int, pos int) {
data := make([]byte, size)
for i := range data {
data[i] = byte('a' + (i % 20))
}
data[pos] = 'Z'
type impl struct {
name string
fn func([]byte, byte, byte) int
}
impls := []impl{
{"asm", lastIndexByteTwo},
{"loop", refLastIndexByteTwo},
}
for _, im := range impls {
b.Run(im.name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
im.fn(data, 'Z', 'z')
}
})
}
}
func BenchmarkIndexByteTwo_10(b *testing.B) { benchIndexByteTwo(b, 10, 8) }
func BenchmarkIndexByteTwo_100(b *testing.B) { benchIndexByteTwo(b, 100, 80) }
func BenchmarkIndexByteTwo_1000(b *testing.B) { benchIndexByteTwo(b, 1000, 800) }
func BenchmarkLastIndexByteTwo_10(b *testing.B) { benchLastIndexByteTwo(b, 10, 2) }
func BenchmarkLastIndexByteTwo_100(b *testing.B) { benchLastIndexByteTwo(b, 100, 20) }
func BenchmarkLastIndexByteTwo_1000(b *testing.B) { benchLastIndexByteTwo(b, 1000, 200) }
+98 -33
View File
@@ -6,6 +6,7 @@ import (
"strings"
"unicode/utf8"
"github.com/junegunn/fzf/src/algo"
"github.com/junegunn/fzf/src/tui"
)
@@ -22,20 +23,21 @@ type url struct {
type ansiState struct {
fg tui.Color
bg tui.Color
ul tui.Color
attr tui.Attr
lbg tui.Color
url *url
}
func (s *ansiState) colored() bool {
return s.fg != -1 || s.bg != -1 || s.attr > 0 || s.lbg >= 0 || s.url != nil
return s.fg != -1 || s.bg != -1 || s.ul != -1 || s.attr > 0 || s.lbg >= 0 || s.url != nil
}
func (s *ansiState) equals(t *ansiState) bool {
if t == nil {
return !s.colored()
}
return s.fg == t.fg && s.bg == t.bg && s.attr == t.attr && s.lbg == t.lbg && s.url == t.url
return s.fg == t.fg && s.bg == t.bg && s.ul == t.ul && s.attr == t.attr && s.lbg == t.lbg && s.url == t.url
}
func (s *ansiState) ToString() string {
@@ -54,7 +56,18 @@ func (s *ansiState) ToString() string {
ret += "3;"
}
if s.attr&tui.Underline > 0 {
ret += "4;"
switch s.attr.UnderlineStyle() {
case tui.UlStyleDouble:
ret += "4:2;"
case tui.UlStyleCurly:
ret += "4:3;"
case tui.UlStyleDotted:
ret += "4:4;"
case tui.UlStyleDashed:
ret += "4:5;"
default:
ret += "4;"
}
}
if s.attr&tui.Blink > 0 {
ret += "5;"
@@ -66,6 +79,9 @@ func (s *ansiState) ToString() string {
ret += "9;"
}
ret += toAnsiString(s.fg, 30) + toAnsiString(s.bg, 40)
if s.ul != -1 {
ret += toAnsiStringUl(s.ul)
}
ret = "\x1b[" + strings.TrimSuffix(ret, ";") + "m"
if s.url != nil {
@@ -74,6 +90,20 @@ func (s *ansiState) ToString() string {
return ret
}
func toAnsiStringUl(color tui.Color) string {
col := int(color)
if col < 0 {
return ""
}
if col >= (1 << 24) {
r := strconv.Itoa((col >> 16) & 0xff)
g := strconv.Itoa((col >> 8) & 0xff)
b := strconv.Itoa(col & 0xff)
return "58;2;" + r + ";" + g + ";" + b + ";"
}
return "58;5;" + strconv.Itoa(col) + ";"
}
func toAnsiString(color tui.Color, offset int) string {
col := int(color)
ret := ""
@@ -94,31 +124,31 @@ func toAnsiString(color tui.Color, offset int) string {
return ret + ";"
}
func isPrint(c uint8) bool {
return '\x20' <= c && c <= '\x7e'
}
func matchOperatingSystemCommand(s string, start int) int {
// `\x1b][0-9][;:][[:print:]]+(?:\x1b\\\\|\x07)`
// ^ match starting here after the first printable character
//
i := start // prefix matched in nextAnsiEscapeSequence()
for ; i < len(s) && isPrint(s[i]); i++ {
// Find the terminator: BEL (\x07) or ESC (\x1b) for ST (\x1b\\)
idx := algo.IndexByteTwo(stringBytes(s[i:]), '\x07', '\x1b')
if idx < 0 {
return -1
}
if i < len(s) {
if s[i] == '\x07' {
return i + 1
}
// `\x1b]8;PARAMS;URI\x1b\\TITLE\x1b]8;;\x1b`
// ------
if s[i] == '\x1b' && i < len(s)-1 && s[i+1] == '\\' {
return i + 2
}
i += idx
if s[i] == '\x07' {
return i + 1
}
// `\x1b]8;PARAMS;URI\x1b\\TITLE\x1b]8;;\x1b`
// ------
if i < len(s)-1 && s[i+1] == '\\' {
return i + 2
}
// `\x1b]8;PARAMS;URI\x1b\\TITLE\x1b]8;;\x1b`
// ------------
if i < len(s) && s[:i+1] == "\x1b]8;;\x1b" {
if s[:i+1] == "\x1b]8;;\x1b" {
return i + 1
}
@@ -204,7 +234,7 @@ Loop:
// \x1b][0-9]+[;:][[:print:]]+(?:\x1b\\\\|\x07)
// ---------------
if j > 2 && i+j+1 < len(s) && (s[i+j] == ';' || s[i+j] == ':') && isPrint(s[i+j+1]) {
if j > 2 && i+j+1 < len(s) && (s[i+j] == ';' || s[i+j] == ':') && s[i+j+1] >= '\x20' {
if k := matchOperatingSystemCommand(s[i:], j+2); k != -1 {
return i, i + k
}
@@ -338,15 +368,19 @@ func extractColor(str string, state *ansiState, proc func(string, *ansiState) bo
return trimmed, nil, state
}
func parseAnsiCode(s string) (int, string) {
func parseAnsiCode(s string) (int, byte, string) {
var remaining string
var i int
// Faster than strings.IndexAny(";:")
i = strings.IndexByte(s, ';')
if i < 0 {
i = strings.IndexByte(s, ':')
var sep byte
// Find the first separator (either ; or :)
i := -1
for j := 0; j < len(s); j++ {
if s[j] == ';' || s[j] == ':' {
i = j
break
}
}
if i >= 0 {
sep = s[i]
remaining = s[i+1:]
s = s[:i]
}
@@ -358,14 +392,14 @@ func parseAnsiCode(s string) (int, string) {
for _, ch := range stringBytes(s) {
ch -= '0'
if ch > 9 {
return -1, remaining
return -1, sep, remaining
}
code = code*10 + int(ch)
}
return code, remaining
return code, sep, remaining
}
return -1, remaining
return -1, sep, remaining
}
func interpretCode(ansiCode string, prevState *ansiState) ansiState {
@@ -373,14 +407,14 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
if prevState != nil {
return *prevState
}
return ansiState{-1, -1, 0, -1, nil}
return ansiState{-1, -1, -1, 0, -1, nil}
}
var state ansiState
if prevState == nil {
state = ansiState{-1, -1, 0, -1, nil}
state = ansiState{-1, -1, -1, 0, -1, nil}
} else {
state = ansiState{prevState.fg, prevState.bg, prevState.attr, prevState.lbg, prevState.url}
state = ansiState{prevState.fg, prevState.bg, prevState.ul, prevState.attr, prevState.lbg, prevState.url}
}
if ansiCode[0] != '\x1b' || ansiCode[1] != '[' || ansiCode[len(ansiCode)-1] != 'm' {
if prevState != nil && (strings.HasSuffix(ansiCode, "0K") || strings.HasSuffix(ansiCode, "[K")) {
@@ -405,6 +439,7 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
reset := func() {
state.fg = -1
state.bg = -1
state.ul = -1
state.attr = 0
}
@@ -420,7 +455,8 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
count := 0
for len(ansiCode) != 0 {
var num int
if num, ansiCode = parseAnsiCode(ansiCode); num != -1 {
var sep byte
if num, sep, ansiCode = parseAnsiCode(ansiCode); num != -1 {
count++
switch state256 {
case 0:
@@ -431,10 +467,15 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
case 48:
ptr = &state.bg
state256++
case 58:
ptr = &state.ul
state256++
case 39:
state.fg = -1
case 49:
state.bg = -1
case 59:
state.ul = -1
case 1:
state.attr = state.attr | tui.Bold
case 2:
@@ -442,7 +483,30 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
case 3:
state.attr = state.attr | tui.Italic
case 4:
state.attr = state.attr | tui.Underline
if sep == ':' {
// SGR 4:N - underline style sub-parameter
var subNum int
subNum, _, ansiCode = parseAnsiCode(ansiCode)
state.attr = state.attr &^ tui.UnderlineStyleMask
switch subNum {
case 0:
state.attr = state.attr &^ tui.Underline
case 1:
state.attr = state.attr | tui.Underline
case 2:
state.attr = state.attr | tui.Underline | tui.UlStyleDouble
case 3:
state.attr = state.attr | tui.Underline | tui.UlStyleCurly
case 4:
state.attr = state.attr | tui.Underline | tui.UlStyleDotted
case 5:
state.attr = state.attr | tui.Underline | tui.UlStyleDashed
default:
state.attr = state.attr | tui.Underline
}
} else {
state.attr = state.attr | tui.Underline
}
case 5:
state.attr = state.attr | tui.Blink
case 7:
@@ -456,6 +520,7 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
state.attr = state.attr &^ tui.Italic
case 24: // tput rmul
state.attr = state.attr &^ tui.Underline
state.attr = state.attr &^ tui.UnderlineStyleMask
case 25:
state.attr = state.attr &^ tui.Blink
case 27:
+127 -21
View File
@@ -41,7 +41,7 @@ func testParserReference(t testing.TB, str string) {
equal := len(got) == len(exp)
if equal {
for i := 0; i < len(got); i++ {
for i := range got {
if got[i] != exp[i] {
equal = false
break
@@ -167,9 +167,9 @@ func TestNextAnsiEscapeSequence_Fuzz_Random(t *testing.T) {
randomString := func(rr *rand.Rand) string {
numChars := rand.Intn(50)
codePoints := make([]rune, numChars)
for i := 0; i < len(codePoints); i++ {
for i := range codePoints {
var r rune
for n := 0; n < 1000; n++ {
for range 1000 {
r = rune(rr.Intn(utf8.MaxRune))
// Allow 10% of runes to be invalid
if utf8.ValidRune(r) || rr.Float64() < 0.10 {
@@ -182,7 +182,7 @@ func TestNextAnsiEscapeSequence_Fuzz_Random(t *testing.T) {
}
rr := rand.New(rand.NewSource(1))
for i := 0; i < 100_000; i++ {
for range 100_000 {
testParserReference(t, randomString(rr))
}
}
@@ -369,10 +369,10 @@ func TestAnsiCodeStringConversion(t *testing.T) {
}
}
assert("\x1b[m", nil, "")
assert("\x1b[m", &ansiState{attr: tui.Blink, lbg: -1}, "")
assert("\x1b[0m", &ansiState{fg: 4, bg: 4, lbg: -1}, "")
assert("\x1b[;m", &ansiState{fg: 4, bg: 4, lbg: -1}, "")
assert("\x1b[;;m", &ansiState{fg: 4, bg: 4, lbg: -1}, "")
assert("\x1b[m", &ansiState{attr: tui.Blink, ul: -1, lbg: -1}, "")
assert("\x1b[0m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "")
assert("\x1b[;m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "")
assert("\x1b[;;m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "")
assert("\x1b[31m", nil, "\x1b[31;49m")
assert("\x1b[41m", nil, "\x1b[39;41m")
@@ -380,36 +380,142 @@ func TestAnsiCodeStringConversion(t *testing.T) {
assert("\x1b[92m", nil, "\x1b[92;49m")
assert("\x1b[102m", nil, "\x1b[39;102m")
assert("\x1b[31m", &ansiState{fg: 4, bg: 4, lbg: -1}, "\x1b[31;44m")
assert("\x1b[1;2;31m", &ansiState{fg: 2, bg: -1, attr: tui.Reverse, lbg: -1}, "\x1b[1;2;7;31;49m")
assert("\x1b[31m", &ansiState{fg: 4, bg: 4, ul: -1, lbg: -1}, "\x1b[31;44m")
assert("\x1b[1;2;31m", &ansiState{fg: 2, bg: -1, ul: -1, attr: tui.Reverse, lbg: -1}, "\x1b[1;2;7;31;49m")
assert("\x1b[38;5;100;48;5;200m", nil, "\x1b[38;5;100;48;5;200m")
assert("\x1b[38:5:100:48:5:200m", nil, "\x1b[38;5;100;48;5;200m")
assert("\x1b[48;5;100;38;5;200m", nil, "\x1b[38;5;200;48;5;100m")
assert("\x1b[48;5;100;38;2;10;20;30;1m", nil, "\x1b[1;38;2;10;20;30;48;5;100m")
assert("\x1b[48;5;100;38;2;10;20;30;7m",
&ansiState{attr: tui.Dim | tui.Italic, fg: 1, bg: 1},
&ansiState{attr: tui.Dim | tui.Italic, fg: 1, bg: 1, ul: -1},
"\x1b[2;3;7;38;2;10;20;30;48;5;100m")
// Underline styles
assert("\x1b[4:3m", nil, "\x1b[4:3;39;49m")
assert("\x1b[4:2m", nil, "\x1b[4:2;39;49m")
assert("\x1b[4:4m", nil, "\x1b[4:4;39;49m")
assert("\x1b[4:5m", nil, "\x1b[4:5;39;49m")
assert("\x1b[4:1m", nil, "\x1b[4;39;49m")
// Underline color (256-color)
assert("\x1b[4;58;5;100m", nil, "\x1b[4;39;49;58;5;100m")
// Underline color (24-bit)
assert("\x1b[4;58;2;255;0;128m", nil, "\x1b[4;39;49;58;2;255;0;128m")
// Curly underline + underline color
assert("\x1b[4:3;58;2;255;0;0m", nil, "\x1b[4:3;39;49;58;2;255;0;0m")
// SGR 59 resets underline color
assert("\x1b[59m", &ansiState{fg: 1, bg: -1, ul: 100, lbg: -1}, "\x1b[31;49m")
}
func TestParseAnsiCode(t *testing.T) {
tests := []struct {
In, Exp string
N int
In string
Exp string
N int
Sep byte
}{
{"123", "", 123},
{"1a", "", -1},
{"1a;12", "12", -1},
{"12;a", "a", 12},
{"-2", "", -1},
{"123", "", 123, 0},
{"1a", "", -1, 0},
{"1a;12", "12", -1, ';'},
{"12;a", "a", 12, ';'},
{"-2", "", -1, 0},
// Colon sub-parameters: earliest separator wins (@shtse8)
{"4:3", "3", 4, ':'},
{"4:3;31", "3;31", 4, ':'},
{"38:2:255:0:0", "2:255:0:0", 38, ':'},
{"58:5:200", "5:200", 58, ':'},
// Semicolon before colon
{"4;38:2:0:0:0", "38:2:0:0:0", 4, ';'},
}
for _, x := range tests {
n, s := parseAnsiCode(x.In)
if n != x.N || s != x.Exp {
t.Fatalf("%q: got: (%d %q) want: (%d %q)", x.In, n, s, x.N, x.Exp)
n, sep, s := parseAnsiCode(x.In)
if n != x.N || s != x.Exp || sep != x.Sep {
t.Fatalf("%q: got: (%d %q %q) want: (%d %q %q)", x.In, n, s, string(sep), x.N, x.Exp, string(x.Sep))
}
}
}
// Test cases adapted from @shtse8 (PR #4678)
func TestInterpretCodeUnderlineStyles(t *testing.T) {
// 4:0 = no underline
state := interpretCode("\x1b[4:0m", nil)
if state.attr&tui.Underline != 0 {
t.Error("4:0 should not set underline")
}
// 4:1 = single underline
state = interpretCode("\x1b[4:1m", nil)
if state.attr&tui.Underline == 0 {
t.Error("4:1 should set underline")
}
// 4:3 = curly underline
state = interpretCode("\x1b[4:3m", nil)
if state.attr&tui.Underline == 0 {
t.Error("4:3 should set underline")
}
if state.attr.UnderlineStyle() != tui.UlStyleCurly {
t.Error("4:3 should set curly underline style")
}
// 4:3 should NOT set italic (3 is a sub-param, not SGR 3)
if state.attr&tui.Italic != 0 {
t.Error("4:3 should not set italic")
}
// 4:2;31 = double underline + red fg
state = interpretCode("\x1b[4:2;31m", nil)
if state.attr&tui.Underline == 0 {
t.Error("4:2;31 should set underline")
}
if state.fg != 1 {
t.Errorf("4:2;31 should set fg to red (1), got %d", state.fg)
}
if state.attr&tui.Dim != 0 {
t.Error("4:2;31 should not set dim")
}
// Plain 4 still works
state = interpretCode("\x1b[4m", nil)
if state.attr&tui.Underline == 0 {
t.Error("4 should set underline")
}
// 4;2 (semicolon) = underline + dim
state = interpretCode("\x1b[4;2m", nil)
if state.attr&tui.Underline == 0 {
t.Error("4;2 should set underline")
}
if state.attr&tui.Dim == 0 {
t.Error("4;2 should set dim")
}
}
// Test cases adapted from @shtse8 (PR #4678)
func TestInterpretCodeUnderlineColor(t *testing.T) {
// 58:2:R:G:B should not affect fg or bg
state := interpretCode("\x1b[58:2:255:0:0m", nil)
if state.fg != -1 || state.bg != -1 {
t.Errorf("58:2:R:G:B should not affect fg/bg, got fg=%d bg=%d", state.fg, state.bg)
}
// 58:5:200 should not affect fg or bg
state = interpretCode("\x1b[58:5:200m", nil)
if state.fg != -1 || state.bg != -1 {
t.Errorf("58:5:N should not affect fg/bg, got fg=%d bg=%d", state.fg, state.bg)
}
// 58:2:R:G:B combined with 38:2:R:G:B should only set fg
state = interpretCode("\x1b[58:2:255:0:0;38:2:0:255:0m", nil)
expectedFg := tui.Color(1<<24 | 0<<16 | 255<<8 | 0)
if state.fg != expectedFg {
t.Errorf("expected fg=%d, got %d", expectedFg, state.fg)
}
if state.bg != -1 {
t.Errorf("bg should be -1, got %d", state.bg)
}
}
// kernel/bpf/preload/iterators/README
const ansiBenchmarkString = "\x1b[38;5;81m\x1b[01;31m\x1b[Kkernel/\x1b[0m\x1b[38:5:81mbpf/" +
"\x1b[0m\x1b[38:5:81mpreload/\x1b[0m\x1b[38;5;81miterators/" +
+18 -15
View File
@@ -2,10 +2,13 @@ package fzf
import "sync"
// queryCache associates strings to lists of items
type queryCache map[string][]Result
// ChunkBitmap is a bitmap with one bit per item in a chunk.
type ChunkBitmap [chunkBitWords]uint64
// ChunkCache associates Chunk and query string to lists of items
// queryCache associates query strings to bitmaps of matching items
type queryCache map[string]ChunkBitmap
// ChunkCache associates Chunk and query string to bitmaps
type ChunkCache struct {
mutex sync.Mutex
cache map[*Chunk]*queryCache
@@ -30,9 +33,9 @@ func (cc *ChunkCache) retire(chunk ...*Chunk) {
cc.mutex.Unlock()
}
// Add adds the list to the cache
func (cc *ChunkCache) Add(chunk *Chunk, key string, list []Result) {
if len(key) == 0 || !chunk.IsFull() || len(list) > queryCacheMax {
// Add stores the bitmap for the given chunk and key
func (cc *ChunkCache) Add(chunk *Chunk, key string, bitmap ChunkBitmap, matchCount int) {
if len(key) == 0 || !chunk.IsFull() || matchCount > queryCacheMax {
return
}
@@ -44,11 +47,11 @@ func (cc *ChunkCache) Add(chunk *Chunk, key string, list []Result) {
cc.cache[chunk] = &queryCache{}
qc = cc.cache[chunk]
}
(*qc)[key] = list
(*qc)[key] = bitmap
}
// Lookup is called to lookup ChunkCache
func (cc *ChunkCache) Lookup(chunk *Chunk, key string) []Result {
// Lookup returns the bitmap for the exact key
func (cc *ChunkCache) Lookup(chunk *Chunk, key string) *ChunkBitmap {
if len(key) == 0 || !chunk.IsFull() {
return nil
}
@@ -58,15 +61,15 @@ func (cc *ChunkCache) Lookup(chunk *Chunk, key string) []Result {
qc, ok := cc.cache[chunk]
if ok {
list, ok := (*qc)[key]
if ok {
return list
if bm, ok := (*qc)[key]; ok {
return &bm
}
}
return nil
}
func (cc *ChunkCache) Search(chunk *Chunk, key string) []Result {
// Search finds the bitmap for the longest prefix or suffix of the key
func (cc *ChunkCache) Search(chunk *Chunk, key string) *ChunkBitmap {
if len(key) == 0 || !chunk.IsFull() {
return nil
}
@@ -86,8 +89,8 @@ func (cc *ChunkCache) Search(chunk *Chunk, key string) []Result {
prefix := key[:len(key)-idx]
suffix := key[idx:]
for _, substr := range [2]string{prefix, suffix} {
if cached, found := (*qc)[substr]; found {
return cached
if bm, found := (*qc)[substr]; found {
return &bm
}
}
}
+11 -11
View File
@@ -6,34 +6,34 @@ func TestChunkCache(t *testing.T) {
cache := NewChunkCache()
chunk1p := &Chunk{}
chunk2p := &Chunk{count: chunkSize}
items1 := []Result{{}}
items2 := []Result{{}, {}}
cache.Add(chunk1p, "foo", items1)
cache.Add(chunk2p, "foo", items1)
cache.Add(chunk2p, "bar", items2)
bm1 := ChunkBitmap{1}
bm2 := ChunkBitmap{1, 2}
cache.Add(chunk1p, "foo", bm1, 1)
cache.Add(chunk2p, "foo", bm1, 1)
cache.Add(chunk2p, "bar", bm2, 2)
{ // chunk1 is not full
cached := cache.Lookup(chunk1p, "foo")
if cached != nil {
t.Error("Cached disabled for non-empty chunks", cached)
t.Error("Cached disabled for non-full chunks", cached)
}
}
{
cached := cache.Lookup(chunk2p, "foo")
if cached == nil || len(cached) != 1 {
t.Error("Expected 1 item cached", cached)
if cached == nil || cached[0] != 1 {
t.Error("Expected bitmap cached", cached)
}
}
{
cached := cache.Lookup(chunk2p, "bar")
if cached == nil || len(cached) != 2 {
t.Error("Expected 2 items cached", cached)
if cached == nil || cached[1] != 2 {
t.Error("Expected bitmap cached", cached)
}
}
{
cached := cache.Lookup(chunk1p, "foobar")
if cached != nil {
t.Error("Expected 0 item cached", cached)
t.Error("Expected nil cached", cached)
}
}
}
+29
View File
@@ -52,6 +52,20 @@ func (cl *ChunkList) lastChunk() *Chunk {
return cl.chunks[len(cl.chunks)-1]
}
// GetItems returns the first n items from the given chunks
func GetItems(chunks []*Chunk, n int) []Item {
items := make([]Item, 0, n)
for _, chunk := range chunks {
for i := 0; i < chunk.count && len(items) < n; i++ {
items = append(items, chunk.items[i])
}
if len(items) >= n {
break
}
}
return items
}
// CountItems returns the total number of Items
func CountItems(cs []*Chunk) int {
if len(cs) == 0 {
@@ -85,6 +99,21 @@ func (cl *ChunkList) Clear() {
cl.mutex.Unlock()
}
// ForEachItem iterates all items and applies fn to each one.
// The done callback runs under the lock to safely update shared state.
func (cl *ChunkList) ForEachItem(fn func(*Item), done func()) {
cl.mutex.Lock()
for _, chunk := range cl.chunks {
for i := 0; i < chunk.count; i++ {
fn(&chunk.items[i])
}
}
if done != nil {
done()
}
cl.mutex.Unlock()
}
// Snapshot returns immutable snapshot of the ChunkList
func (cl *ChunkList) Snapshot(tail int) ([]*Chunk, int, bool) {
cl.mutex.Lock()
+2 -2
View File
@@ -51,7 +51,7 @@ func TestChunkList(t *testing.T) {
}
// Add more data
for i := 0; i < chunkSize*2; i++ {
for i := range chunkSize * 2 {
cl.Push(fmt.Appendf(nil, "item %d", i))
}
@@ -85,7 +85,7 @@ func TestChunkListTail(t *testing.T) {
return true
})
total := chunkSize*2 + chunkSize/2
for i := 0; i < total; i++ {
for i := range total {
cl.Push(fmt.Appendf(nil, "item %d", i))
}
+4 -6
View File
@@ -34,19 +34,18 @@ const (
maxBgProcessesPerAction = 3
// Matcher
numPartitionsMultiplier = 8
maxPartitions = 32
progressMinDuration = 200 * time.Millisecond
progressMinDuration = 200 * time.Millisecond
// Capacity of each chunk
chunkSize int = 100
chunkSize int = 1024
chunkBitWords = (chunkSize + 63) / 64
// Pre-allocated memory slices to minimize GC
slab16Size int = 100 * 1024 // 200KB * 32 = 12.8MB
slab32Size int = 2048 // 8KB * 32 = 256KB
// Do not cache results of low selectivity queries
queryCacheMax int = chunkSize / 5
queryCacheMax int = chunkSize / 2
// Not to cache mergers with large lists
mergerCacheMax int = 100000
@@ -65,7 +64,6 @@ const (
EvtSearchNew
EvtSearchProgress
EvtSearchFin
EvtHeader
EvtReady
EvtQuit
)
+186 -81
View File
@@ -2,6 +2,8 @@
package fzf
import (
"fmt"
"maps"
"os"
"sync"
"time"
@@ -16,7 +18,6 @@ Reader -> EvtReadNew -> Matcher (restart)
Terminal -> EvtSearchNew:bool -> Matcher (restart)
Matcher -> EvtSearchProgress -> Terminal (update info)
Matcher -> EvtSearchFin -> Terminal (update list)
Matcher -> EvtHeader -> Terminal (update header)
*/
type revision struct {
@@ -37,12 +38,27 @@ func (r revision) compatible(other revision) bool {
return r.major == other.major
}
func buildItemTransformer(opts *Options) func(*Item) string {
if opts.AcceptNth != nil {
fn := opts.AcceptNth(opts.Delimiter)
return func(item *Item) string {
return item.acceptNth(opts.Ansi, opts.Delimiter, fn)
}
}
return func(item *Item) string {
return item.AsString(opts.Ansi)
}
}
// Run starts fzf
func Run(opts *Options) (int, error) {
if opts.Filter == nil {
if opts.useTmux() {
return runTmux(os.Args, opts)
}
if opts.useZellij() {
return runZellij(os.Args, opts)
}
if needWinpty(opts) {
return runWinpty(os.Args, opts)
@@ -100,57 +116,57 @@ func Run(opts *Options) (int, error) {
cache := NewChunkCache()
var chunkList *ChunkList
var itemIndex int32
header := make([]string, 0, opts.HeaderLines)
// transformItem applies with-nth transformation to an item's raw data.
// It handles ANSI token propagation using prevLineAnsiState for cross-line continuity.
transformItem := func(item *Item, data []byte, transformer func([]Token, int32) string, index int32) {
tokens := Tokenize(byteString(data), opts.Delimiter)
if opts.Ansi && len(tokens) > 1 {
var ansiState *ansiState
if prevLineAnsiState != nil {
ansiStateDup := *prevLineAnsiState
ansiState = &ansiStateDup
}
for _, token := range tokens {
prevAnsiState := ansiState
_, _, ansiState = extractColor(token.text.ToString(), ansiState, nil)
if prevAnsiState != nil {
token.text.Prepend("\x1b[m" + prevAnsiState.ToString())
} else {
token.text.Prepend("\x1b[m")
}
}
}
transformed := transformer(tokens, index)
item.text, item.colors = ansiProcessor(stringBytes(transformed))
// We should not trim trailing whitespaces with background colors
var maxColorOffset int32
if item.colors != nil {
for _, ansi := range *item.colors {
if ansi.color.bg >= 0 {
maxColorOffset = max(maxColorOffset, ansi.offset[1])
}
}
}
item.text.TrimTrailingWhitespaces(int(maxColorOffset))
}
var nthTransformer func([]Token, int32) string
if opts.WithNth == nil {
chunkList = NewChunkList(cache, func(item *Item, data []byte) bool {
if len(header) < opts.HeaderLines {
header = append(header, byteString(data))
eventBox.Set(EvtHeader, header)
return false
}
item.text, item.colors = ansiProcessor(data)
item.text.Index = itemIndex
itemIndex++
return true
})
} else {
nthTransformer := opts.WithNth(opts.Delimiter)
nthTransformer = opts.WithNth(opts.Delimiter)
chunkList = NewChunkList(cache, func(item *Item, data []byte) bool {
tokens := Tokenize(byteString(data), opts.Delimiter)
if opts.Ansi && len(tokens) > 1 {
var ansiState *ansiState
if prevLineAnsiState != nil {
ansiStateDup := *prevLineAnsiState
ansiState = &ansiStateDup
}
for _, token := range tokens {
prevAnsiState := ansiState
_, _, ansiState = extractColor(token.text.ToString(), ansiState, nil)
if prevAnsiState != nil {
token.text.Prepend("\x1b[m" + prevAnsiState.ToString())
} else {
token.text.Prepend("\x1b[m")
}
}
if nthTransformer == nil {
item.text, item.colors = ansiProcessor(data)
} else {
transformItem(item, data, nthTransformer, itemIndex)
}
transformed := nthTransformer(tokens, itemIndex)
if len(header) < opts.HeaderLines {
header = append(header, transformed)
eventBox.Set(EvtHeader, header)
return false
}
item.text, item.colors = ansiProcessor(stringBytes(transformed))
// We should not trim trailing whitespaces with background colors
var maxColorOffset int32
if item.colors != nil {
for _, ansi := range *item.colors {
if ansi.color.bg >= 0 {
maxColorOffset = util.Max32(maxColorOffset, ansi.offset[1])
}
}
}
item.text.TrimTrailingWhitespaces(int(maxColorOffset))
item.text.Index = itemIndex
item.origText = &data
itemIndex++
@@ -180,13 +196,15 @@ func Run(opts *Options) (int, error) {
}
// Reader
streamingFilter := opts.Filter != nil && !sort && !opts.Tac && !opts.Sync
streamingFilter := opts.Filter != nil && !sort && !opts.Tac && !opts.Sync && opts.Bench == 0
var reader *Reader
var ingestionStart time.Time
if !streamingFilter {
reader = NewReader(func(data []byte) bool {
return chunkList.Push(data)
}, eventBox, executor, opts.ReadZero, opts.Filter == nil)
ingestionStart = time.Now()
readyChan := make(chan bool)
go reader.ReadSource(opts.Input, opts.WalkerRoot, opts.WalkerOpts, opts.WalkerSkip, initialReload, initialEnv, readyChan)
<-readyChan
@@ -223,18 +241,17 @@ func Run(opts *Options) (int, error) {
denylist = make(map[int32]struct{})
denyMutex.Unlock()
}
headerLines := int32(opts.HeaderLines)
headerUpdated := false
patternBuilder := func(runes []rune) *Pattern {
denyMutex.Lock()
denylistCopy := make(map[int32]struct{})
for k, v := range denylist {
denylistCopy[k] = v
}
denylistCopy := maps.Clone(denylist)
denyMutex.Unlock()
return BuildPattern(cache, patternCache,
opts.Fuzzy, opts.FuzzyAlgo, opts.Extended, opts.Case, opts.Normalize, forward, withPos,
opts.Filter == nil, nth, opts.Delimiter, inputRevision, runes, denylistCopy)
opts.Filter == nil, nth, opts.Delimiter, inputRevision, runes, denylistCopy, headerLines)
}
matcher := NewMatcher(cache, patternBuilder, sort, opts.Tac, eventBox, inputRevision)
matcher := NewMatcher(cache, patternBuilder, sort, opts.Tac, eventBox, inputRevision, opts.Threads)
// Filtering mode
if opts.Filter != nil {
@@ -245,6 +262,8 @@ func Run(opts *Options) (int, error) {
pattern := patternBuilder([]rune(*opts.Filter))
matcher.sort = pattern.sortable
transformer := buildItemTransformer(opts)
found := false
if streamingFilter {
slab := util.MakeSlab(slab16Size, slab32Size)
@@ -253,9 +272,12 @@ func Run(opts *Options) (int, error) {
func(runes []byte) bool {
item := Item{}
if chunkList.trans(&item, runes) {
if item.Index() < headerLines {
return false
}
mutex.Lock()
if result, _, _ := pattern.MatchItem(&item, false, slab); result != nil {
opts.Printer(item.text.ToString())
if result, _, _ := pattern.MatchItem(&item, false, slab); result.item != nil {
opts.Printer(transformer(&item))
found = true
}
mutex.Unlock()
@@ -266,14 +288,56 @@ func Run(opts *Options) (int, error) {
} else {
eventBox.Unwatch(EvtReadNew)
eventBox.WaitFor(EvtReadFin)
ingestionTime := time.Since(ingestionStart)
// NOTE: Streaming filter is inherently not compatible with --tail
snapshot, _, _ := chunkList.Snapshot(opts.Tail)
merger, _ := matcher.scan(MatchRequest{
if opts.Bench > 0 {
// Benchmark mode: repeat scan for the given duration
totalItems := CountItems(snapshot)
var matchCount int
var times []time.Duration
deadline := time.Now().Add(opts.Bench)
for time.Now().Before(deadline) {
cache.Clear()
start := time.Now()
result := matcher.scan(MatchRequest{
chunks: snapshot,
pattern: pattern})
times = append(times, time.Since(start))
matchCount = result.merger.Length()
}
// Print stats
var total time.Duration
minD, maxD := times[0], times[0]
for _, d := range times {
total += d
if d < minD {
minD = d
}
if d > maxD {
maxD = d
}
}
avg := total / time.Duration(len(times))
selectivity := float64(matchCount) / float64(totalItems) * 100
fmt.Printf(" %d iterations avg: %.2fms min: %.2fms max: %.2fms total: %.2fs items: %d matches: %d (%.2f%%) ingestion: %.2fms\n",
len(times),
float64(avg.Microseconds())/1000,
float64(minD.Microseconds())/1000,
float64(maxD.Microseconds())/1000,
total.Seconds(),
totalItems, matchCount, selectivity,
float64(ingestionTime.Microseconds())/1000)
return ExitOk, nil
}
result := matcher.scan(MatchRequest{
chunks: snapshot,
pattern: pattern})
for i := 0; i < merger.Length(); i++ {
opts.Printer(merger.Get(i).item.AsString(opts.Ansi))
for i := 0; i < result.merger.Length(); i++ {
opts.Printer(transformer(result.merger.Get(i).item))
found = true
}
}
@@ -318,10 +382,11 @@ func Run(opts *Options) (int, error) {
query := []rune{}
determine := func(final bool) {
if heightUnknown {
if total >= maxFit || final {
items := max(0, total-int(headerLines))
if items >= maxFit || final {
deferred = false
heightUnknown = false
terminal.startChan <- fitpad{util.Min(total, maxFit), padHeight}
terminal.startChan <- fitpad{min(items, maxFit), padHeight}
}
} else if deferred {
deferred = false
@@ -337,11 +402,11 @@ func Run(opts *Options) (int, error) {
clearDenylist()
}
reading = true
headerUpdated = false
startTick = ticks
chunkList.Clear()
itemIndex = 0
inputRevision.bumpMajor()
header = make([]string, 0, opts.HeaderLines)
readyChan := make(chan bool)
go reader.restart(command, environ, readyChan)
<-readyChan
@@ -399,16 +464,24 @@ func Run(opts *Options) (int, error) {
snapshotRevision = inputRevision
}
total = count
terminal.UpdateCount(total, !reading, value.(*string))
terminal.UpdateCount(max(0, total-int(headerLines)), !reading, value.(*string))
if headerLines > 0 && !headerUpdated {
terminal.UpdateHeader(GetItems(snapshot, int(headerLines)))
headerUpdated = int32(total) >= headerLines
}
if heightUnknown && !deferred {
determine(!reading)
}
matcher.Reset(snapshot, input(), false, !reading, sort, snapshotRevision)
if !useSnapshot || evt == EvtReadFin {
matcher.Reset(snapshot, input(), false, !reading, sort, snapshotRevision)
}
case EvtSearchNew:
var command *commandSpec
var environ []string
var changed bool
headerLinesChanged := false
withNthChanged := false
switch val := value.(type) {
case searchRequest:
sort = val.sort
@@ -429,6 +502,40 @@ func Run(opts *Options) (int, error) {
nth = *val.nth
bump = true
}
if val.headerLines != nil {
headerLines = int32(*val.headerLines)
headerUpdated = false
headerLinesChanged = true
bump = true
}
if val.withNth != nil {
newTransformer := val.withNth.fn
// Cancel any in-flight scan and block the terminal from reading
// items before mutating them in-place. Snapshot shares middle
// chunk pointers, so the matcher and terminal can race with us.
matcher.CancelScan()
terminal.PauseRendering()
// Reset cross-line ANSI state before re-processing all items
lineAnsiState = nil
prevLineAnsiState = nil
chunkList.ForEachItem(func(item *Item) {
origBytes := *item.origText
savedIndex := item.Index()
if newTransformer != nil {
transformItem(item, origBytes, newTransformer, savedIndex)
} else {
item.text, item.colors = ansiProcessor(origBytes)
}
item.text.Index = savedIndex
item.transformed = nil
}, func() {
nthTransformer = newTransformer
})
terminal.ResumeRendering()
matcher.ResumeScan()
withNthChanged = true
bump = true
}
if bump {
patternCache = make(map[string]*Pattern)
cache.Clear()
@@ -465,6 +572,16 @@ func Run(opts *Options) (int, error) {
snapshotRevision = inputRevision
}
}
if headerLinesChanged {
terminal.UpdateCount(max(0, total-int(headerLines)), !reading, nil)
if headerLines > 0 {
terminal.UpdateHeader(GetItems(snapshot, int(headerLines)))
} else {
terminal.UpdateHeader(nil)
}
} else if withNthChanged && headerLines > 0 {
terminal.UpdateHeader(GetItems(snapshot, int(headerLines)))
}
matcher.Reset(snapshot, input(), true, !reading, sort, snapshotRevision)
delay = false
@@ -474,19 +591,15 @@ func Run(opts *Options) (int, error) {
terminal.UpdateProgress(val)
}
case EvtHeader:
headerPadded := make([]string, opts.HeaderLines)
copy(headerPadded, value.([]string))
terminal.UpdateHeader(headerPadded)
case EvtSearchFin:
switch val := value.(type) {
case *Merger:
case MatchResult:
merger := val.merger
if deferred {
count := val.Length()
count := merger.Length()
if opts.Select1 && count > 1 || opts.Exit0 && !opts.Select1 && count > 0 {
determine(val.final)
} else if val.final {
determine(merger.final)
} else if merger.final {
if opts.Exit0 && count == 0 || opts.Select1 && count == 1 {
if opts.PrintQuery {
opts.Printer(opts.Query)
@@ -494,17 +607,9 @@ func Run(opts *Options) (int, error) {
if len(opts.Expect) > 0 {
opts.Printer("")
}
transformer := func(item *Item) string {
return item.AsString(opts.Ansi)
}
if opts.AcceptNth != nil {
fn := opts.AcceptNth(opts.Delimiter)
transformer = func(item *Item) string {
return item.acceptNth(opts.Ansi, opts.Delimiter, fn)
}
}
for i := 0; i < count; i++ {
opts.Printer(transformer(val.Get(i).item))
transformer := buildItemTransformer(opts)
for i := range count {
opts.Printer(transformer(merger.Get(i).item))
}
if count == 0 {
exitCode = ExitNoMatch
@@ -512,7 +617,7 @@ func Run(opts *Options) (int, error) {
stop = true
return
}
determine(val.final)
determine(merger.final)
}
}
terminal.UpdateList(val)
@@ -525,7 +630,7 @@ func Run(opts *Options) (int, error) {
break
}
if delay && reading {
dur := util.DurWithin(
dur := util.Constrain(
time.Duration(ticks-startTick)*coordinatorDelayStep,
0, coordinatorDelayMax)
time.Sleep(dur)
+1 -1
View File
@@ -38,7 +38,7 @@ func TestHistory(t *testing.T) {
if len(h.lines) != maxHistory+1 {
t.Errorf("Expected: %d, actual: %d\n", maxHistory+1, len(h.lines))
}
for i := 0; i < maxHistory; i++ {
for i := range maxHistory {
if h.lines[i] != "foobar" {
t.Error("Expected: foobar, actual: " + h.lines[i])
}
+86 -73
View File
@@ -3,8 +3,8 @@ package fzf
import (
"fmt"
"runtime"
"sort"
"sync"
"sync/atomic"
"time"
"github.com/junegunn/fzf/src/util"
@@ -19,6 +19,20 @@ type MatchRequest struct {
revision revision
}
type MatchResult struct {
merger *Merger
passMerger *Merger
cancelled bool
}
func (mr MatchResult) cacheable() bool {
return mr.merger != nil && mr.merger.cacheable()
}
func (mr MatchResult) final() bool {
return mr.merger != nil && mr.merger.final
}
// Matcher is responsible for performing search
type Matcher struct {
cache *ChunkCache
@@ -29,8 +43,11 @@ type Matcher struct {
reqBox *util.EventBox
partitions int
slab []*util.Slab
mergerCache map[string]*Merger
sortBuf [][]Result
mergerCache map[string]MatchResult
revision revision
scanMutex sync.Mutex
cancelScan *util.AtomicBool
}
const (
@@ -40,8 +57,11 @@ const (
// NewMatcher returns a new Matcher
func NewMatcher(cache *ChunkCache, patternBuilder func([]rune) *Pattern,
sort bool, tac bool, eventBox *util.EventBox, revision revision) *Matcher {
partitions := util.Min(numPartitionsMultiplier*runtime.NumCPU(), maxPartitions)
sort bool, tac bool, eventBox *util.EventBox, revision revision, threads int) *Matcher {
partitions := runtime.NumCPU()
if threads > 0 {
partitions = threads
}
return &Matcher{
cache: cache,
patternBuilder: patternBuilder,
@@ -51,8 +71,10 @@ func NewMatcher(cache *ChunkCache, patternBuilder func([]rune) *Pattern,
reqBox: util.NewEventBox(),
partitions: partitions,
slab: make([]*util.Slab, partitions),
mergerCache: make(map[string]*Merger),
revision: revision}
sortBuf: make([][]Result, partitions),
mergerCache: make(map[string]MatchResult),
revision: revision,
cancelScan: util.NewAtomicBool(false)}
}
// Loop puts Matcher in action
@@ -85,126 +107,102 @@ func (m *Matcher) Loop() {
cacheCleared := false
if request.sort != m.sort || request.revision != m.revision {
m.sort = request.sort
m.revision = request.revision
m.mergerCache = make(map[string]*Merger)
m.mergerCache = make(map[string]MatchResult)
if !request.revision.compatible(m.revision) {
m.cache.Clear()
}
m.revision = request.revision
cacheCleared = true
}
// Restart search
patternString := request.pattern.AsString()
var merger *Merger
cancelled := false
var result MatchResult
count := CountItems(request.chunks)
if !cacheCleared {
if count == prevCount {
// Look up mergerCache
if cached, found := m.mergerCache[patternString]; found && cached.final == request.final {
merger = cached
if cached, found := m.mergerCache[patternString]; found && cached.final() == request.final {
result = cached
}
} else {
// Invalidate mergerCache
prevCount = count
m.mergerCache = make(map[string]*Merger)
m.mergerCache = make(map[string]MatchResult)
}
}
if merger == nil {
merger, cancelled = m.scan(request)
if result.merger == nil {
m.scanMutex.Lock()
result = m.scan(request)
m.scanMutex.Unlock()
}
if !cancelled {
if merger.cacheable() {
m.mergerCache[patternString] = merger
if !result.cancelled {
if result.cacheable() {
m.mergerCache[patternString] = result
}
merger.final = request.final
m.eventBox.Set(EvtSearchFin, merger)
result.merger.final = request.final
m.eventBox.Set(EvtSearchFin, result)
}
}
}
func (m *Matcher) sliceChunks(chunks []*Chunk) [][]*Chunk {
partitions := m.partitions
perSlice := len(chunks) / partitions
if perSlice == 0 {
partitions = len(chunks)
perSlice = 1
}
slices := make([][]*Chunk, partitions)
for i := 0; i < partitions; i++ {
start := i * perSlice
end := start + perSlice
if i == partitions-1 {
end = len(chunks)
}
slices[i] = chunks[start:end]
}
return slices
}
type partialResult struct {
index int
matches []Result
}
func (m *Matcher) scan(request MatchRequest) (*Merger, bool) {
func (m *Matcher) scan(request MatchRequest) MatchResult {
startedAt := time.Now()
numChunks := len(request.chunks)
if numChunks == 0 {
return EmptyMerger(request.revision), false
m := EmptyMerger(request.revision)
return MatchResult{m, m, false}
}
pattern := request.pattern
passMerger := PassMerger(&request.chunks, m.tac, request.revision, pattern.startIndex)
if pattern.IsEmpty() {
return PassMerger(&request.chunks, m.tac, request.revision), false
return MatchResult{passMerger, passMerger, false}
}
minIndex := request.chunks[0].items[0].Index()
maxIndex := request.chunks[numChunks-1].lastIndex(minIndex)
cancelled := util.NewAtomicBool(false)
slices := m.sliceChunks(request.chunks)
numSlices := len(slices)
resultChan := make(chan partialResult, numSlices)
numWorkers := min(m.partitions, numChunks)
var nextChunk atomic.Int32
resultChan := make(chan partialResult, numWorkers)
countChan := make(chan int, numChunks)
waitGroup := sync.WaitGroup{}
for idx, chunks := range slices {
for idx := range numWorkers {
waitGroup.Add(1)
if m.slab[idx] == nil {
m.slab[idx] = util.MakeSlab(slab16Size, slab32Size)
}
go func(idx int, slab *util.Slab, chunks []*Chunk) {
defer func() { waitGroup.Done() }()
count := 0
allMatches := make([][]Result, len(chunks))
for idx, chunk := range chunks {
matches := request.pattern.Match(chunk, slab)
allMatches[idx] = matches
count += len(matches)
go func(idx int, slab *util.Slab) {
defer waitGroup.Done()
var matches []Result
for {
ci := int(nextChunk.Add(1)) - 1
if ci >= numChunks {
break
}
chunkMatches := request.pattern.Match(request.chunks[ci], slab)
matches = append(matches, chunkMatches...)
if cancelled.Get() {
return
}
countChan <- len(matches)
}
sliceMatches := make([]Result, 0, count)
for _, matches := range allMatches {
sliceMatches = append(sliceMatches, matches...)
countChan <- len(chunkMatches)
}
if m.sort && request.pattern.sortable {
if m.tac {
sort.Sort(ByRelevanceTac(sliceMatches))
} else {
sort.Sort(ByRelevance(sliceMatches))
}
m.sortBuf[idx] = radixSortResults(matches, m.tac, m.sortBuf[idx])
}
resultChan <- partialResult{idx, sliceMatches}
}(idx, m.slab[idx], chunks)
resultChan <- partialResult{idx, matches}
}(idx, m.slab[idx])
}
wait := func() bool {
@@ -223,8 +221,8 @@ func (m *Matcher) scan(request MatchRequest) (*Merger, bool) {
break
}
if m.reqBox.Peek(reqReset) {
return nil, wait()
if m.cancelScan.Get() || m.reqBox.Peek(reqReset) {
return MatchResult{nil, nil, wait()}
}
if time.Since(startedAt) > progressMinDuration {
@@ -232,12 +230,13 @@ func (m *Matcher) scan(request MatchRequest) (*Merger, bool) {
}
}
partialResults := make([][]Result, numSlices)
for range slices {
partialResults := make([][]Result, numWorkers)
for range numWorkers {
partialResult := <-resultChan
partialResults[partialResult.index] = partialResult.matches
}
return NewMerger(pattern, partialResults, m.sort && request.pattern.sortable, m.tac, request.revision, minIndex, maxIndex), false
merger := NewMerger(pattern, partialResults, m.sort && request.pattern.sortable, m.tac, request.revision, minIndex, maxIndex)
return MatchResult{merger, passMerger, false}
}
// Reset is called to interrupt/signal the ongoing search
@@ -253,6 +252,20 @@ func (m *Matcher) Reset(chunks []*Chunk, patternRunes []rune, cancel bool, final
m.reqBox.Set(event, MatchRequest{chunks, pattern, final, sort, revision})
}
// CancelScan cancels any in-flight scan, waits for it to finish,
// and prevents new scans from starting until ResumeScan is called.
// This is used to safely mutate shared items (e.g., during with-nth changes).
func (m *Matcher) CancelScan() {
m.cancelScan.Set(true)
m.scanMutex.Lock()
m.cancelScan.Set(false)
}
// ResumeScan allows scans to proceed again after CancelScan.
func (m *Matcher) ResumeScan() {
m.scanMutex.Unlock()
}
func (m *Matcher) Stop() {
m.reqBox.Set(reqQuit, nil)
}
+38 -31
View File
@@ -10,42 +10,46 @@ func EmptyMerger(revision revision) *Merger {
// Merger holds a set of locally sorted lists of items and provides the view of
// a single, globally-sorted list
type Merger struct {
pattern *Pattern
lists [][]Result
merged []Result
chunks *[]*Chunk
cursors []int
sorted bool
tac bool
final bool
count int
pass bool
revision revision
minIndex int32
maxIndex int32
pattern *Pattern
lists [][]Result
merged []Result
chunks *[]*Chunk
cursors []int
sorted bool
tac bool
final bool
count int
pass bool
startIndex int
revision revision
minIndex int32
maxIndex int32
}
// PassMerger returns a new Merger that simply returns the items in the
// original order
func PassMerger(chunks *[]*Chunk, tac bool, revision revision) *Merger {
// original order. startIndex items are skipped from the beginning.
func PassMerger(chunks *[]*Chunk, tac bool, revision revision, startIndex int32) *Merger {
var minIndex, maxIndex int32
if len(*chunks) > 0 {
minIndex = (*chunks)[0].items[0].Index()
maxIndex = (*chunks)[len(*chunks)-1].lastIndex(minIndex)
}
si := int(startIndex)
mg := Merger{
pattern: nil,
chunks: chunks,
tac: tac,
count: 0,
pass: true,
revision: revision,
minIndex: minIndex,
maxIndex: maxIndex}
pattern: nil,
chunks: chunks,
tac: tac,
count: 0,
pass: true,
startIndex: si,
revision: revision,
minIndex: minIndex + startIndex,
maxIndex: maxIndex}
for _, chunk := range *mg.chunks {
mg.count += chunk.count
}
mg.count = max(0, mg.count-si)
return &mg
}
@@ -113,6 +117,7 @@ func (mg *Merger) Get(idx int) Result {
if mg.tac {
idx = mg.count - idx - 1
}
idx += mg.startIndex
firstChunk := (*mg.chunks)[0]
if firstChunk.count < chunkSize && idx >= firstChunk.count {
idx -= firstChunk.count
@@ -131,14 +136,16 @@ func (mg *Merger) Get(idx int) Result {
if mg.tac {
idx = mg.count - idx - 1
}
for _, list := range mg.lists {
numItems := len(list)
if idx < numItems {
return list[idx]
}
idx -= numItems
return mg.mergedGet(idx)
}
func (mg *Merger) ToMap() map[int32]Result {
ret := make(map[int32]Result, mg.count)
for i := 0; i < mg.count; i++ {
result := mg.Get(i)
ret[result.Index()] = result
}
panic(fmt.Sprintf("Index out of bounds (unsorted, %d/%d)", idx, mg.count))
return ret
}
func (mg *Merger) cacheable() bool {
@@ -157,7 +164,7 @@ func (mg *Merger) mergedGet(idx int) Result {
}
if cursor >= 0 {
rank := list[cursor]
if minIdx < 0 || compareRanks(rank, minRank, mg.tac) {
if minIdx < 0 || mg.sorted && compareRanks(rank, minRank, mg.tac) || !mg.sorted && rank.item.Index() < minRank.item.Index() {
minRank = rank
minIdx = listIdx
}
+21 -6
View File
@@ -34,11 +34,11 @@ func buildLists(partiallySorted bool) ([][]Result, []Result) {
numLists := 4
lists := make([][]Result, numLists)
cnt := 0
for i := 0; i < numLists; i++ {
for i := range numLists {
numResults := rand.Int() % 20
cnt += numResults
lists[i] = make([]Result, numResults)
for j := 0; j < numResults; j++ {
for j := range numResults {
item := randResult()
lists[i][j] = item
}
@@ -54,13 +54,28 @@ func buildLists(partiallySorted bool) ([][]Result, []Result) {
}
func TestMergerUnsorted(t *testing.T) {
lists, items := buildLists(false)
lists, _ := buildLists(false)
// Sort each list by index to simulate real worker behavior
// (workers process chunks in ascending order via nextChunk.Add(1))
for _, list := range lists {
sort.Slice(list, func(i, j int) bool {
return list[i].item.Index() < list[j].item.Index()
})
}
items := []Result{}
for _, list := range lists {
items = append(items, list...)
}
sort.Slice(items, func(i, j int) bool {
return items[i].item.Index() < items[j].item.Index()
})
cnt := len(items)
// Not sorted: same order
// Not sorted: items in ascending index order
mg := NewMerger(nil, lists, false, false, revision{}, 0, 0)
assert(t, cnt == mg.Length(), "Invalid Length")
for i := 0; i < cnt; i++ {
for i := range cnt {
assert(t, items[i] == mg.Get(i), "Invalid Get")
}
}
@@ -73,7 +88,7 @@ func TestMergerSorted(t *testing.T) {
mg := NewMerger(nil, lists, true, false, revision{}, 0, 0)
assert(t, cnt == mg.Length(), "Invalid Length")
sort.Sort(ByRelevance(items))
for i := 0; i < cnt; i++ {
for i := range cnt {
if items[i] != mg.Get(i) {
t.Error("Not sorted", items[i], mg.Get(i))
}
+264 -70
View File
@@ -3,10 +3,12 @@ package fzf
import (
"errors"
"fmt"
"maps"
"os"
"regexp"
"strconv"
"strings"
"time"
"unicode"
"github.com/junegunn/fzf/src/algo"
@@ -64,7 +66,7 @@ Usage: fzf [options]
--no-bold Do not use bold text
DISPLAY MODE
--height=[~]HEIGHT[%] Display fzf window below the cursor with the given
--height=[~][-]HEIGHT[%] Display fzf window below the cursor with the given
height instead of using fullscreen.
A negative value is calculated as the terminal height
minus the given value.
@@ -73,9 +75,10 @@ Usage: fzf [options]
--min-height=HEIGHT[+] Minimum height when --height is given as a percentage.
Add '+' to automatically increase the value
according to the other layout options (default: 10+).
--tmux[=OPTS] Start fzf in a tmux popup (requires tmux 3.3+)
--popup[=OPTS] Start fzf in a popup window (requires tmux 3.3+ or Zellij 0.44+)
[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
[,border-native] (default: center,50%)
--tmux[=OPTS] Alias for --popup
LAYOUT
--layout=LAYOUT Choose layout: [default|reverse|reverse-list]
@@ -94,14 +97,18 @@ Usage: fzf [options]
-m, --multi[=MAX] Enable multi-select with tab/shift-tab
--highlight-line Highlight the whole current line
--cycle Enable cyclic scroll
--wrap Enable line wrap
--wrap[=MODE] Enable line wrap (char|word, default: char)
--wrap-sign=STR Indicator for wrapped lines
--no-multi-line Disable multi-line display of items when using --read0
--raw Enable raw mode (show non-matching items)
--track Track the current selection when the result is updated
--id-nth=N[,..] Define item identity fields for cross-reload operations
--tac Reverse the order of the input
--gap[=N] Render empty lines between each item
--gap-line[=STR] Draw horizontal line on each gap using the string
(default: '┈' or '-')
--freeze-left=N Number of fields to freeze on the left
--freeze-right=N Number of fields to freeze on the right
--keep-right Keep the right end of the line visible on overflow
--scroll-off=LINES Number of screen lines to keep above or below when
scrolling to the top or to the bottom (default: 0)
@@ -109,6 +116,8 @@ Usage: fzf [options]
--hscroll-off=COLS Number of screen columns to keep to the right of the
highlighted substring (default: 10)
--jump-labels=CHARS Label characters for jump mode
--gutter=CHAR Character used for the gutter column (default: '▌')
--gutter-raw=CHAR Character used for the gutter column in raw mode (default: '▖')
--pointer=STR Pointer to the current line (default: '▌' or '>')
--marker=STR Multi-select marker (default: '┃' or '>')
--marker-multi-line=STR Multi-select marker for multi-line entries;
@@ -151,7 +160,7 @@ Usage: fzf [options]
--preview=COMMAND Command to preview highlighted line ({})
--preview-window=OPT Preview window layout (default: right:50%)
[up|down|left|right][,SIZE[%]]
[,[no]wrap][,[no]cycle][,[no]follow][,[no]info]
[,[no]wrap[-word]][,[no]cycle][,[no]follow][,[no]info]
[,[no]hidden][,border-STYLE]
[,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES]
[,default][,<SIZE_THRESHOLD(ALTERNATIVE_LAYOUT)]
@@ -161,6 +170,7 @@ Usage: fzf [options]
--preview-label=LABEL
--preview-label-pos=N Same as --border-label and --border-label-pos,
but for preview window
--preview-wrap-sign=STR Indicator for wrapped lines in the preview window
HEADER
--header=STR String to print as header
@@ -168,10 +178,11 @@ Usage: fzf [options]
--header-first Print header before the prompt line
--header-border[=STYLE] Draw border around the header section
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
top|bottom|left|right|line|none] (default: rounded)
top|bottom|left|right|line|inline|none] (default: rounded)
--header-lines-border[=STYLE]
Display header from --header-lines with a separate border.
Pass 'none' to still separate it but without a border.
Pass 'inline' to embed it inside the list frame.
--header-label=LABEL Label to print on the header border
--header-label-pos=COL Position of the header label
[POSITIVE_INTEGER: columns from left|
@@ -182,7 +193,7 @@ Usage: fzf [options]
--footer=STR String to print as footer
--footer-border[=STYLE] Draw border around the footer section
[rounded|sharp|bold|block|thinblock|double|horizontal|vertical|
top|bottom|left|right|line|none] (default: line)
top|bottom|left|right|line|inline|none] (default: line)
--footer-label=LABEL Label to print on the footer border
--footer-label-pos=COL Position of the footer label
[POSITIVE_INTEGER: columns from left|
@@ -202,8 +213,10 @@ Usage: fzf [options]
ADVANCED
--with-shell=STR Shell command and flags to start child processes with
--listen[=[ADDR:]PORT] Start HTTP server to receive actions (POST /)
--listen[=[ADDR:]PORT] Start HTTP server to receive actions via TCP
(To allow remote process execution, use --listen-unsafe)
--listen=SOCKET_PATH Start HTTP server to receive actions via Unix domain socket
(Path should end with .sock)
DIRECTORY TRAVERSAL (Only used when $FZF_DEFAULT_COMMAND is not set)
--walker=OPTS [file][,dir][,follow][,hidden] (default: file,follow,hidden)
@@ -212,8 +225,8 @@ Usage: fzf [options]
(default: .git,node_modules)
HISTORY
--history=FILE History file
--history-size=N Maximum number of history entries (default: 1000)
--history=FILE File to store fzf search history (*not* shell command history)
--history-size=N Maximum number of entries to keep in the file (default: 1000)
SHELL INTEGRATION
--bash Print script to set up Bash shell integration
@@ -284,14 +297,32 @@ func defaultMargin() [4]sizeSpec {
return [4]sizeSpec{}
}
type trackOption int
type trackOption struct {
enabled bool
index int32
}
const (
trackDisabled trackOption = iota
trackEnabled
trackCurrent
var (
trackDisabled = trackOption{false, minItem.Index()}
trackEnabled = trackOption{true, minItem.Index()}
)
func (t trackOption) Disabled() bool {
return !t.enabled
}
func (t trackOption) Global() bool {
return t.enabled && t.index == minItem.Index()
}
func (t trackOption) Current() bool {
return t.enabled && t.index != minItem.Index()
}
func trackCurrent(index int32) trackOption {
return trackOption{true, index}
}
type windowPosition int
const (
@@ -341,6 +372,7 @@ type previewOpts struct {
scroll string
hidden bool
wrap bool
wrapWord bool
cycle bool
follow bool
info bool
@@ -387,7 +419,7 @@ func parseTmuxOptions(arg string, index int) (*tmuxOptions, error) {
var err error
opts := defaultTmuxOptions(index)
tokens := splitRegexp.Split(arg, -1)
errorToReturn := errors.New("invalid tmux 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 {
return nil, errorToReturn
}
@@ -517,7 +549,7 @@ func (o *previewOpts) compare(active *previewOpts, b *previewOpts) previewOptsCo
return previewOptsDifferentLayout
}
if a.wrap == b.wrap && a.headerLines == b.headerLines && a.info == b.info && a.scroll == b.scroll {
if a.wrap == b.wrap && a.wrapWord == b.wrapWord && a.headerLines == b.headerLines && a.info == b.info && a.scroll == b.scroll {
return previewOptsSame
}
@@ -556,17 +588,23 @@ type Options struct {
Case Case
Normalize bool
Nth []Range
FreezeLeft int
FreezeRight int
WithNth func(Delimiter) func([]Token, int32) string
WithNthExpr string
AcceptNth func(Delimiter) func([]Token, int32) string
Delimiter Delimiter
Sort int
Raw bool
Track trackOption
IdNth []Range
Tac bool
Tail int
Criteria []criterion
Multi int
Ansi bool
Mouse bool
BaseTheme *tui.ColorTheme
Theme *tui.ColorTheme
Black bool
Bold bool
@@ -575,7 +613,9 @@ type Options struct {
Layout layoutType
Cycle bool
Wrap bool
WrapWord bool
WrapSign *string
PreviewWrapSign *string
MultiLine bool
CursorLine bool
KeepRight bool
@@ -591,6 +631,7 @@ type Options struct {
JumpLabels string
Prompt string
Gutter *string
GutterRaw *string
Pointer *string
Marker *string
MarkerMulti *[3]string
@@ -642,6 +683,8 @@ type Options struct {
WalkerSkip []string
Version bool
Help bool
Threads int
Bench time.Duration
CPUProfile string
MEMProfile string
BlockProfile string
@@ -660,15 +703,22 @@ func filterNonEmpty(input []string) []string {
}
func defaultPreviewOpts(command string) previewOpts {
return previewOpts{command, posRight, sizeSpec{50, true}, "", false, false, false, false, true, defaultBorderShape, 0, 0, nil}
return previewOpts{
command: command,
position: posRight,
size: sizeSpec{50, true},
info: true,
border: defaultBorderShape,
}
}
func defaultOptions() *Options {
var theme *tui.ColorTheme
var theme, baseTheme *tui.ColorTheme
if os.Getenv("NO_COLOR") != "" {
theme = tui.NoColorTheme()
theme = tui.NoColorTheme
baseTheme = tui.NoColorTheme
} else {
theme = tui.EmptyTheme()
theme = tui.EmptyTheme
}
return &Options{
@@ -694,12 +744,14 @@ func defaultOptions() *Options {
Ansi: false,
Mouse: true,
Theme: theme,
BaseTheme: baseTheme,
Black: false,
Bold: true,
MinHeight: -10,
Layout: layoutDefault,
Cycle: false,
Wrap: false,
WrapWord: false,
MultiLine: true,
KeepRight: false,
Hscroll: true,
@@ -712,6 +764,7 @@ func defaultOptions() *Options {
JumpLabels: defaultJumpLabels,
Prompt: "> ",
Gutter: nil,
GutterRaw: nil,
Pointer: nil,
Marker: nil,
MarkerMulti: nil,
@@ -818,7 +871,7 @@ func nthTransformer(str string) (func(Delimiter) func([]Token, int32) string, er
nth []Range
}
parts := make([]NthParts, len(indexes))
parts := make([]NthParts, 0, len(indexes))
idx := 0
for _, index := range indexes {
if idx < index[0] {
@@ -901,6 +954,8 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) {
switch str {
case "line":
return tui.BorderLine, nil
case "inline":
return tui.BorderInline, nil
case "rounded":
return tui.BorderRounded, nil
case "sharp":
@@ -931,7 +986,7 @@ func parseBorder(str string, optional bool) (tui.BorderShape, error) {
if optional && str == "" {
return defaultBorderShape, nil
}
return tui.BorderNone, errors.New("invalid border style (expected: rounded|sharp|bold|block|thinblock|double|horizontal|vertical|top|bottom|left|right|none)")
return tui.BorderNone, errors.New("invalid border style (expected: rounded|sharp|bold|block|thinblock|double|horizontal|vertical|top|bottom|left|right|line|inline|none)")
}
func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Event, error) {
@@ -1201,11 +1256,20 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
default:
runes := []rune(key)
if len(key) == 10 && strings.HasPrefix(lkey, "ctrl-alt-") && isAlphabet(lkey[9]) {
evt := tui.CtrlAltKey(rune(key[9]))
r := rune(lkey[9])
evt := tui.CtrlAltKey(r)
if r == 'h' && !util.IsWindows() {
evt = tui.CtrlAltBackspace.AsEvent()
}
chords[evt] = key
list = append(list, evt)
} else if len(key) == 6 && strings.HasPrefix(lkey, "ctrl-") && isAlphabet(lkey[5]) {
add(tui.EventType(tui.CtrlA.Int() + int(lkey[5]) - 'a'))
evt := tui.EventType(tui.CtrlA.Int() + int(lkey[5]) - 'a')
r := rune(lkey[5])
if r == 'h' && !util.IsWindows() {
evt = tui.CtrlBackspace
}
add(evt)
} else if len(runes) == 5 && strings.HasPrefix(lkey, "alt-") {
r := runes[4]
switch r {
@@ -1310,8 +1374,9 @@ func dupeTheme(theme *tui.ColorTheme) *tui.ColorTheme {
return &dupe
}
func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, error) {
func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, *tui.ColorTheme, error) {
var err error
var baseTheme *tui.ColorTheme
theme := dupeTheme(defaultTheme)
rrggbb := regexp.MustCompile("^#[0-9a-fA-F]{6}$")
comma := regexp.MustCompile(`[\s,]+`)
@@ -1322,13 +1387,17 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, erro
}
switch str {
case "dark":
baseTheme = tui.Dark256
theme = dupeTheme(tui.Dark256)
case "light":
baseTheme = tui.Light256
theme = dupeTheme(tui.Light256)
case "base16", "16":
baseTheme = tui.Default16
theme = dupeTheme(tui.Default16)
case "bw", "no":
theme = tui.NoColorTheme()
baseTheme = tui.NoColorTheme
theme = dupeTheme(tui.NoColorTheme)
default:
fail := func() {
// Let the code proceed to simplify the error handling
@@ -1353,10 +1422,20 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, erro
cattr.Attr |= tui.Bold
case "dim":
cattr.Attr |= tui.Dim
case "strip":
cattr.Attr |= tui.Strip
case "italic":
cattr.Attr |= tui.Italic
case "underline":
cattr.Attr |= tui.Underline
case "underline-double":
cattr.Attr |= tui.Underline | tui.UlStyleDouble
case "underline-curly":
cattr.Attr |= tui.Underline | tui.UlStyleCurly
case "underline-dotted":
cattr.Attr |= tui.Underline | tui.UlStyleDotted
case "underline-dashed":
cattr.Attr |= tui.Underline | tui.UlStyleDashed
case "blink":
cattr.Attr |= tui.Blink
case "reverse":
@@ -1440,8 +1519,12 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, erro
mergeAttr(&theme.SelectedBg)
case "nth":
mergeAttr(&theme.Nth)
case "nomatch":
mergeAttr(&theme.Nomatch)
case "gutter":
mergeAttr(&theme.Gutter)
case "alt-gutter":
mergeAttr(&theme.AltGutter)
case "hl":
mergeAttr(&theme.Match)
case "current-hl", "hl+":
@@ -1505,7 +1588,7 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, erro
}
}
}
return theme, err
return baseTheme, theme, err
}
func parseWalkerOpts(str string) (walkerOpts, error) {
@@ -1533,7 +1616,7 @@ func parseWalkerOpts(str string) (walkerOpts, error) {
}
var (
executeRegexp *regexp.Regexp
argActionRegexp *regexp.Regexp
splitRegexp *regexp.Regexp
actionNameRegexp *regexp.Regexp
)
@@ -1552,8 +1635,8 @@ const (
)
func init() {
executeRegexp = regexp.MustCompile(
`(?si)[:+](become|execute(?:-multi|-silent)?|reload(?:-sync)?|preview|(?:change|bg-transform|transform)-(?:query|prompt|(?:border|list|preview|input|header|footer)-label|header|footer|search|nth|pointer|ghost)|bg-transform|transform|change-(?:preview-window|preview|multi)|(?:re|un|toggle-)bind|pos|put|print|search|trigger)`)
argActionRegexp = regexp.MustCompile(
`(?si)[:+](become|execute(?:-multi|-silent)?|reload(?:-sync)?|preview|(?:change|bg-transform|transform)-(?:query|prompt|(?:border|list|preview|input|header|footer)-label|header-lines|header|footer|search|with-nth|nth|pointer|ghost)|bg-transform|transform|change-(?:preview-window|preview|multi)|(?:re|un|toggle-)bind|pos|put|print|search|trigger)`)
splitRegexp = regexp.MustCompile("[,:]+")
actionNameRegexp = regexp.MustCompile("(?i)^[a-z-]+")
}
@@ -1562,7 +1645,7 @@ func maskActionContents(action string) string {
masked := ""
Loop:
for len(action) > 0 {
loc := executeRegexp.FindStringIndex(action)
loc := argActionRegexp.FindStringIndex(action)
if loc == nil {
masked += action
break
@@ -1617,7 +1700,7 @@ Loop:
}
func parseSingleActionList(str string) ([]*action, error) {
// We prepend a colon to satisfy executeRegexp and remove it later
// We prepend a colon to satisfy argActionRegexp and remove it later
masked := maskActionContents(":" + str)[1:]
return parseActionList(masked, str, []*action{}, false)
}
@@ -1735,10 +1818,18 @@ func parseActionList(masked string, original string, prevActions []*action, putA
appendAction(actToggleHeader)
case "toggle-wrap":
appendAction(actToggleWrap)
case "toggle-wrap-word":
appendAction(actToggleWrapWord)
case "toggle-multi-line":
appendAction(actToggleMultiLine)
case "toggle-hscroll":
appendAction(actToggleHscroll)
case "toggle-raw":
appendAction(actToggleRaw)
case "enable-raw":
appendAction(actEnableRaw)
case "disable-raw":
appendAction(actDisableRaw)
case "show-header":
appendAction(actShowHeader)
case "hide-header":
@@ -1759,12 +1850,18 @@ func parseActionList(masked string, original string, prevActions []*action, putA
appendAction(actToggle)
case "down":
appendAction(actDown)
case "down-match":
appendAction(actDownMatch)
case "up":
appendAction(actUp)
case "up-match":
appendAction(actUpMatch)
case "first", "top":
appendAction(actFirst)
case "last":
appendAction(actLast)
case "best":
appendAction(actBest)
case "page-up":
appendAction(actPageUp)
case "page-down":
@@ -1777,9 +1874,9 @@ func parseActionList(masked string, original string, prevActions []*action, putA
appendAction(actPrevHistory)
case "next-history":
appendAction(actNextHistory)
case "prev-selected":
case "up-selected", "prev-selected":
appendAction(actPrevSelected)
case "next-selected":
case "down-selected", "next-selected":
appendAction(actNextSelected)
case "show-preview":
appendAction(actShowPreview)
@@ -1789,6 +1886,8 @@ func parseActionList(masked string, original string, prevActions []*action, putA
appendAction(actTogglePreview)
case "toggle-preview-wrap":
appendAction(actTogglePreviewWrap)
case "toggle-preview-wrap-word":
appendAction(actTogglePreviewWrapWord)
case "toggle-sort":
appendAction(actToggleSort)
case "offset-up":
@@ -1948,6 +2047,8 @@ func isExecuteAction(str string) actionType {
return actPreview
case "change-header":
return actChangeHeader
case "change-header-lines":
return actChangeHeaderLines
case "change-footer":
return actChangeFooter
case "change-list-label":
@@ -1978,6 +2079,8 @@ func isExecuteAction(str string) actionType {
return actChangeMulti
case "change-nth":
return actChangeNth
case "change-with-nth":
return actChangeWithNth
case "pos":
return actPosition
case "execute":
@@ -2008,10 +2111,14 @@ func isExecuteAction(str string) actionType {
return actTransformFooter
case "transform-header":
return actTransformHeader
case "transform-header-lines":
return actTransformHeaderLines
case "transform-ghost":
return actTransformGhost
case "transform-nth":
return actTransformNth
case "transform-with-nth":
return actTransformWithNth
case "transform-pointer":
return actTransformPointer
case "transform-prompt":
@@ -2038,10 +2145,14 @@ func isExecuteAction(str string) actionType {
return actBgTransformFooter
case "bg-transform-header":
return actBgTransformHeader
case "bg-transform-header-lines":
return actBgTransformHeaderLines
case "bg-transform-ghost":
return actBgTransformGhost
case "bg-transform-nth":
return actBgTransformNth
case "bg-transform-with-nth":
return actBgTransformWithNth
case "bg-transform-pointer":
return actBgTransformPointer
case "bg-transform-prompt":
@@ -2114,9 +2225,6 @@ func parseHeight(str string, index int) (heightSpec, error) {
str = str[1:]
}
if strings.HasPrefix(str, "-") {
if heightSpec.auto {
return heightSpec, errors.New("negative(-) height is not compatible with adaptive(~) height")
}
heightSpec.inverse = true
str = str[1:]
}
@@ -2200,8 +2308,13 @@ func parsePreviewWindowImpl(opts *previewOpts, input string) error {
opts.hidden = false
case "wrap":
opts.wrap = true
opts.wrapWord = false
case "wrap-word":
opts.wrap = true
opts.wrapWord = true
case "nowrap":
opts.wrap = false
opts.wrapWord = false
case "cycle":
opts.cycle = true
case "nocycle":
@@ -2524,7 +2637,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
opts.Version = true
case "--no-winpty":
opts.NoWinpty = true
case "--tmux":
case "--tmux", "--popup":
given, str := optionalNextString()
if given {
if opts.Tmux, err = parseTmuxOptions(str, index); err != nil {
@@ -2533,7 +2646,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
} else {
opts.Tmux = defaultTmuxOptions(index)
}
case "--no-tmux":
case "--no-tmux", "--no-popup":
opts.Tmux = nil
case "--tty-default":
if opts.TtyDefault, err = nextString("tty device name required"); err != nil {
@@ -2602,9 +2715,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
if err != nil {
return err
}
for k, v := range chords {
opts.Expect[k] = v
}
maps.Copy(opts.Expect, chords)
case "--no-expect":
opts.Expect = make(map[tui.Event]string)
case "--enabled", "--no-phony":
@@ -2632,11 +2743,15 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
case "--color":
_, spec := optionalNextString()
if len(spec) == 0 {
opts.Theme = tui.EmptyTheme()
opts.Theme = tui.EmptyTheme
} else {
if opts.Theme, err = parseTheme(opts.Theme, spec); err != nil {
var baseTheme *tui.ColorTheme
if baseTheme, opts.Theme, err = parseTheme(opts.Theme, spec); err != nil {
return err
}
if baseTheme != nil {
opts.BaseTheme = baseTheme
}
}
case "--toggle-sort":
str, err := nextString("key name required")
@@ -2660,6 +2775,14 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
if opts.Nth, err = splitNth(str); err != nil {
return err
}
case "--freeze-left":
if opts.FreezeLeft, err = nextInt("number of fields required"); err != nil {
return err
}
case "--freeze-right":
if opts.FreezeRight, err = nextInt("number of fields required"); err != nil {
return err
}
case "--with-nth":
str, err := nextString("nth expression required")
if err != nil {
@@ -2668,6 +2791,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
if opts.WithNth, err = nthTransformer(str); err != nil {
return err
}
opts.WithNthExpr = str
case "--accept-nth":
str, err := nextString("nth expression required")
if err != nil {
@@ -2682,10 +2806,24 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
}
case "+s", "--no-sort":
opts.Sort = 0
case "--raw":
opts.Raw = true
case "--no-raw":
opts.Raw = false
case "--track":
opts.Track = trackEnabled
case "--no-track":
opts.Track = trackDisabled
case "--id-nth":
str, err := nextString("nth expression required")
if err != nil {
return err
}
if opts.IdNth, err = splitNth(str); err != nil {
return err
}
case "--no-id-nth":
opts.IdNth = nil
case "--tac":
opts.Tac = true
case "--no-tac":
@@ -2718,7 +2856,8 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
case "--no-mouse":
opts.Mouse = false
case "+c", "--no-color":
opts.Theme = tui.NoColorTheme()
opts.BaseTheme = tui.NoColorTheme
opts.Theme = tui.NoColorTheme
case "+2", "--no-256":
opts.Theme = tui.Default16
case "--black":
@@ -2750,9 +2889,29 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
case "--no-cycle":
opts.Cycle = false
case "--wrap":
opts.Wrap = true
given, str := optionalNextString()
if given {
switch str {
case "char":
opts.Wrap = true
opts.WrapWord = false
case "word":
opts.Wrap = true
opts.WrapWord = true
default:
return errors.New("invalid wrap mode: " + str + " (expected: char or word)")
}
} else {
opts.Wrap = true
}
case "--no-wrap":
opts.Wrap = false
opts.WrapWord = false
case "--wrap-word":
opts.Wrap = true
opts.WrapWord = true
case "--no-wrap-word":
opts.WrapWord = false
case "--wrap-sign":
str, err := nextString("wrap sign required")
if err != nil {
@@ -2866,6 +3025,13 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
}
str = firstLine(str)
opts.Gutter = &str
case "--gutter-raw":
str, err := nextString("gutter character for raw mode required")
if err != nil {
return err
}
str = firstLine(str)
opts.GutterRaw = &str
case "--pointer":
str, err := nextString("pointer sign required")
if err != nil {
@@ -2979,8 +3145,14 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
if opts.Preview.border, err = parseBorder(arg, !hasArg); err != nil {
return err
}
case "--preview-wrap-sign":
str, err := nextString("preview wrap sign required")
if err != nil {
return err
}
opts.PreviewWrapSign = &str
case "--height":
str, err := nextString("height required: [~]HEIGHT[%]")
str, err := nextString("height required: [~][-]HEIGHT[%]")
if err != nil {
return err
}
@@ -3225,6 +3397,23 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
return err
}
opts.WalkerSkip = filterNonEmpty(strings.Split(str, ","))
case "--threads":
if opts.Threads, err = nextInt("number of threads required"); err != nil {
return err
}
if opts.Threads < 0 {
return errors.New("--threads must be a positive integer")
}
case "--bench":
str, err := nextString("duration required (e.g. 3s, 500ms)")
if err != nil {
return err
}
dur, err := time.ParseDuration(str)
if err != nil {
return errors.New("invalid duration for --bench: " + str)
}
opts.Bench = dur
case "--profile-cpu":
if opts.CPUProfile, err = nextString("file path required: cpu"); err != nil {
return err
@@ -3291,6 +3480,10 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
return errors.New("empty jump labels")
}
if opts.FreezeLeft < 0 || opts.FreezeRight < 0 {
return errors.New("number of fields to freeze must be a non-negative integer")
}
if validateJumpLabels {
for _, r := range opts.JumpLabels {
if r < 32 || r > 126 {
@@ -3326,7 +3519,9 @@ func applyPreset(opts *Options, preset string) error {
opts.Preview.border = tui.BorderLine
opts.Preview.info = false
opts.InfoStyle = infoDefault
opts.Theme.Gutter = tui.ColorAttr{Color: -1, Attr: 0}
opts.Theme.Gutter = tui.NewColorAttr()
space := " "
opts.Gutter = &space
empty := ""
opts.Separator = &empty
opts.Scrollbar = &empty
@@ -3384,10 +3579,9 @@ func validateOptions(opts *Options) error {
}
}
if opts.Gutter != nil {
if err := validateSign(*opts.Gutter, "gutter", 1); err != nil {
return err
}
if opts.Gutter != nil && uniseg.StringWidth(*opts.Gutter) != 1 ||
opts.GutterRaw != nil && uniseg.StringWidth(*opts.GutterRaw) != 1 {
return errors.New("gutter display width should be 1")
}
if opts.Scrollbar != nil {
@@ -3402,7 +3596,7 @@ func validateOptions(opts *Options) error {
}
}
if opts.Height.auto {
if opts.Height.auto && (opts.Tmux == nil || opts.Tmux.index < opts.Height.index) {
for _, s := range []sizeSpec{opts.Margin[0], opts.Margin[2]} {
if s.percent {
return errors.New("adaptive height is not compatible with top/bottom percent margin")
@@ -3419,6 +3613,19 @@ func validateOptions(opts *Options) error {
return errors.New("only ANSI attributes are allowed for 'nth' (regular, bold, underline, reverse, dim, italic, strikethrough)")
}
if opts.BorderShape == tui.BorderInline ||
opts.ListBorderShape == tui.BorderInline ||
opts.InputBorderShape == tui.BorderInline ||
opts.Preview.border == tui.BorderInline {
return errors.New("inline border is only supported for --header-border, --header-lines-border, and --footer-border")
}
if opts.HeaderBorderShape == tui.BorderInline &&
opts.HeaderLinesShape != tui.BorderInline &&
opts.HeaderLinesShape != tui.BorderUndefined &&
opts.HeaderLinesShape != tui.BorderNone {
return errors.New("--header-border=inline requires --header-lines-border to be inline or unset")
}
return nil
}
@@ -3436,6 +3643,10 @@ func (opts *Options) useTmux() bool {
return opts.Tmux != nil && len(os.Getenv("TMUX")) > 0 && opts.Tmux.index >= opts.Height.index
}
func (opts *Options) useZellij() bool {
return opts.Tmux != nil && len(os.Getenv("ZELLIJ")) > 0 && opts.Tmux.index >= opts.Height.index
}
func (opts *Options) noSeparatorLine() bool {
if opts.Inputless {
return true
@@ -3589,23 +3800,6 @@ func postProcessOptions(opts *Options) error {
}
}
if opts.Bold {
theme := opts.Theme
boldify := func(c tui.ColorAttr) tui.ColorAttr {
dup := c
if (c.Attr & tui.AttrRegular) == 0 {
dup.Attr |= tui.BoldForce
}
return dup
}
theme.Current = boldify(theme.Current)
theme.CurrentMatch = boldify(theme.CurrentMatch)
theme.Prompt = boldify(theme.Prompt)
theme.Input = boldify(theme.Input)
theme.Cursor = boldify(theme.Cursor)
theme.Spinner = boldify(theme.Spinner)
}
// If --height option is not supported on the platform, just ignore it
if !tui.IsLightRendererSupported() && opts.Height.size > 0 {
opts.Height = heightSpec{}
+71 -6
View File
@@ -300,8 +300,12 @@ func TestBind(t *testing.T) {
}
func TestColorSpec(t *testing.T) {
var base *tui.ColorTheme
theme := tui.Dark256
dark, _ := parseTheme(theme, "dark")
base, dark, _ := parseTheme(theme, "dark")
if *dark != *base {
t.Errorf("incorrect base theme returned")
}
if *dark != *theme {
t.Errorf("colors should be equivalent")
}
@@ -309,7 +313,10 @@ func TestColorSpec(t *testing.T) {
t.Errorf("point should not be equivalent")
}
light, _ := parseTheme(theme, "dark,light")
base, light, _ := parseTheme(theme, "dark,light")
if *light != *base {
t.Errorf("incorrect base theme returned")
}
if *light == *theme {
t.Errorf("should not be equivalent")
}
@@ -320,7 +327,7 @@ func TestColorSpec(t *testing.T) {
t.Errorf("point should not be equivalent")
}
customized, _ := parseTheme(theme, "fg:231,bg:232")
_, customized, _ := parseTheme(theme, "fg:231,bg:232")
if customized.Fg.Color != 231 || customized.Bg.Color != 232 {
t.Errorf("color not customized")
}
@@ -333,7 +340,7 @@ func TestColorSpec(t *testing.T) {
t.Errorf("colors should now be equivalent: %v, %v", tui.Dark256, customized)
}
customized, _ = parseTheme(theme, "fg:231,dark bg:232")
_, customized, _ = parseTheme(theme, "fg:231,dark bg:232")
if customized.Fg != tui.Dark256.Fg || customized.Bg == tui.Dark256.Bg {
t.Errorf("color not customized")
}
@@ -350,8 +357,8 @@ func TestDefaultCtrlNP(t *testing.T) {
t.Error()
}
}
check([]string{}, tui.CtrlN, actDown)
check([]string{}, tui.CtrlP, actUp)
check([]string{}, tui.CtrlN, actDownMatch)
check([]string{}, tui.CtrlP, actUpMatch)
check([]string{"--bind=ctrl-n:accept"}, tui.CtrlN, actAccept)
check([]string{"--bind=ctrl-p:accept"}, tui.CtrlP, actAccept)
@@ -441,6 +448,64 @@ func TestPreviewOpts(t *testing.T) {
opts.Preview.size.size == 70) {
t.Error(opts.Preview)
}
// wrap-word tests
opts = optsFor("--preview-window=wrap-word")
if !(opts.Preview.wrap == true && opts.Preview.wrapWord == true) {
t.Errorf("wrap-word: wrap=%v, wrapWord=%v", opts.Preview.wrap, opts.Preview.wrapWord)
}
opts = optsFor("--preview-window=wrap-word,nowrap")
if !(opts.Preview.wrap == false && opts.Preview.wrapWord == false) {
t.Errorf("wrap-word,nowrap: wrap=%v, wrapWord=%v", opts.Preview.wrap, opts.Preview.wrapWord)
}
opts = optsFor("--preview-window=wrap-word,wrap")
if !(opts.Preview.wrap == true && opts.Preview.wrapWord == false) {
t.Errorf("wrap-word,wrap: wrap=%v, wrapWord=%v", opts.Preview.wrap, opts.Preview.wrapWord)
}
}
func TestPreviewWrapSign(t *testing.T) {
// Default: no preview wrap sign override
opts := optsFor()
if opts.PreviewWrapSign != nil {
t.Errorf("expected nil PreviewWrapSign, got %v", *opts.PreviewWrapSign)
}
// --preview-wrap-sign sets PreviewWrapSign
opts = optsFor("--preview-wrap-sign", ">> ")
if opts.PreviewWrapSign == nil || *opts.PreviewWrapSign != ">> " {
t.Errorf("expected '>> ', got %v", opts.PreviewWrapSign)
}
// --preview-wrap-sign is independent of --wrap-sign
opts = optsFor("--wrap-sign", "| ", "--preview-wrap-sign", ">> ")
if opts.WrapSign == nil || *opts.WrapSign != "| " {
t.Errorf("expected WrapSign '| ', got %v", opts.WrapSign)
}
if opts.PreviewWrapSign == nil || *opts.PreviewWrapSign != ">> " {
t.Errorf("expected PreviewWrapSign '>> ', got %v", opts.PreviewWrapSign)
}
// --preview-wrap-sign without --wrap-sign
opts = optsFor("--preview-wrap-sign", "→ ")
if opts.WrapSign != nil {
t.Errorf("expected nil WrapSign, got %v", *opts.WrapSign)
}
if opts.PreviewWrapSign == nil || *opts.PreviewWrapSign != "→ " {
t.Errorf("expected PreviewWrapSign '→ ', got %v", opts.PreviewWrapSign)
}
// Last --preview-wrap-sign wins
opts = optsFor("--preview-wrap-sign", "A ", "--preview-wrap-sign", "B ")
if opts.PreviewWrapSign == nil || *opts.PreviewWrapSign != "B " {
t.Errorf("expected PreviewWrapSign 'B ', got %v", opts.PreviewWrapSign)
}
// Empty string is allowed
opts = optsFor("--preview-wrap-sign", "")
if opts.PreviewWrapSign == nil || *opts.PreviewWrapSign != "" {
t.Errorf("expected empty PreviewWrapSign, got %v", opts.PreviewWrapSign)
}
}
func TestAdditiveExpect(t *testing.T) {
+91 -50
View File
@@ -61,9 +61,12 @@ type Pattern struct {
delimiter Delimiter
nth []Range
revision revision
procFun map[termType]algo.Algo
procFun [6]algo.Algo
cache *ChunkCache
denylist map[int32]struct{}
startIndex int32
directAlgo algo.Algo
directTerm *term
}
var _splitRegex *regexp.Regexp
@@ -74,7 +77,7 @@ func init() {
// BuildPattern builds Pattern object from the given arguments
func BuildPattern(cache *ChunkCache, patternCache map[string]*Pattern, fuzzy bool, fuzzyAlgo algo.Algo, extended bool, caseMode Case, normalize bool, forward bool,
withPos bool, cacheable bool, nth []Range, delimiter Delimiter, revision revision, runes []rune, denylist map[int32]struct{}) *Pattern {
withPos bool, cacheable bool, nth []Range, delimiter Delimiter, revision revision, runes []rune, denylist map[int32]struct{}, startIndex int32) *Pattern {
var asString string
if extended {
@@ -146,9 +149,11 @@ func BuildPattern(cache *ChunkCache, patternCache map[string]*Pattern, fuzzy boo
delimiter: delimiter,
cache: cache,
denylist: denylist,
procFun: make(map[termType]algo.Algo)}
startIndex: startIndex,
}
ptr.cacheKey = ptr.buildCacheKey()
ptr.directAlgo, ptr.directTerm = ptr.buildDirectAlgo(fuzzyAlgo)
ptr.procFun[termFuzzy] = fuzzyAlgo
ptr.procFun[termEqual] = algo.EqualMatch
ptr.procFun[termExact] = algo.ExactMatchNaive
@@ -272,6 +277,22 @@ func (p *Pattern) buildCacheKey() string {
return strings.Join(cacheableTerms, "\t")
}
// buildDirectAlgo returns the algo function and term for the direct fast path
// in matchChunk. Returns (nil, nil) if the pattern is not suitable.
// Requirements: extended mode, single term set with single non-inverse fuzzy term, no nth.
func (p *Pattern) buildDirectAlgo(fuzzyAlgo algo.Algo) (algo.Algo, *term) {
if !p.extended || len(p.nth) > 0 {
return nil, nil
}
if len(p.termSets) == 1 && len(p.termSets[0]) == 1 {
t := &p.termSets[0][0]
if !t.inv && t.typ == termFuzzy {
return fuzzyAlgo, t
}
}
return nil, nil
}
// CacheKey is used to build string to be used as the key of result cache
func (p *Pattern) CacheKey() string {
return p.cacheKey
@@ -279,84 +300,104 @@ func (p *Pattern) CacheKey() string {
// Match returns the list of matches Items in the given Chunk
func (p *Pattern) Match(chunk *Chunk, slab *util.Slab) []Result {
// ChunkCache: Exact match
cacheKey := p.CacheKey()
// Bitmap cache: exact match or prefix/suffix
var cachedBitmap *ChunkBitmap
if p.cacheable {
if cached := p.cache.Lookup(chunk, cacheKey); cached != nil {
return cached
}
cachedBitmap = p.cache.Lookup(chunk, cacheKey)
}
if cachedBitmap == nil {
cachedBitmap = p.cache.Search(chunk, cacheKey)
}
// Prefix/suffix cache
space := p.cache.Search(chunk, cacheKey)
matches := p.matchChunk(chunk, space, slab)
matches, bitmap := p.matchChunk(chunk, cachedBitmap, slab)
if p.cacheable {
p.cache.Add(chunk, cacheKey, matches)
p.cache.Add(chunk, cacheKey, bitmap, len(matches))
}
return matches
}
func (p *Pattern) matchChunk(chunk *Chunk, space []Result, slab *util.Slab) []Result {
func (p *Pattern) matchChunk(chunk *Chunk, cachedBitmap *ChunkBitmap, slab *util.Slab) ([]Result, ChunkBitmap) {
matches := []Result{}
var bitmap ChunkBitmap
// Skip header items in chunks that contain them
startIdx := 0
if p.startIndex > 0 && chunk.count > 0 && chunk.items[0].Index() < p.startIndex {
startIdx = int(p.startIndex - chunk.items[0].Index())
if startIdx >= chunk.count {
return matches, bitmap
}
}
hasCachedBitmap := cachedBitmap != nil
// Fast path: single fuzzy term, no nth, no denylist.
// Calls the algo function directly, bypassing MatchItem/extendedMatch/iter
// and avoiding per-match []Offset heap allocation.
if p.directAlgo != nil && len(p.denylist) == 0 {
t := p.directTerm
for idx := startIdx; idx < chunk.count; idx++ {
if hasCachedBitmap && cachedBitmap[idx/64]&(uint64(1)<<(idx%64)) == 0 {
continue
}
res, _ := p.directAlgo(t.caseSensitive, t.normalize, p.forward,
&chunk.items[idx].text, t.text, p.withPos, slab)
if res.Start >= 0 {
bitmap[idx/64] |= uint64(1) << (idx % 64)
matches = append(matches, buildResultFromBounds(
&chunk.items[idx], res.Score,
int(res.Start), int(res.End), int(res.End), true))
}
}
return matches, bitmap
}
if len(p.denylist) == 0 {
// Huge code duplication for minimizing unnecessary map lookups
if space == nil {
for idx := 0; idx < chunk.count; idx++ {
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match != nil {
matches = append(matches, *match)
}
for idx := startIdx; idx < chunk.count; idx++ {
if hasCachedBitmap && cachedBitmap[idx/64]&(uint64(1)<<(idx%64)) == 0 {
continue
}
} else {
for _, result := range space {
if match, _, _ := p.MatchItem(result.item, p.withPos, slab); match != nil {
matches = append(matches, *match)
}
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match.item != nil {
bitmap[idx/64] |= uint64(1) << (idx % 64)
matches = append(matches, match)
}
}
return matches
return matches, bitmap
}
if space == nil {
for idx := 0; idx < chunk.count; idx++ {
if _, prs := p.denylist[chunk.items[idx].Index()]; prs {
continue
}
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match != nil {
matches = append(matches, *match)
}
for idx := startIdx; idx < chunk.count; idx++ {
if hasCachedBitmap && cachedBitmap[idx/64]&(uint64(1)<<(idx%64)) == 0 {
continue
}
} else {
for _, result := range space {
if _, prs := p.denylist[result.item.Index()]; prs {
continue
}
if match, _, _ := p.MatchItem(result.item, p.withPos, slab); match != nil {
matches = append(matches, *match)
}
if _, prs := p.denylist[chunk.items[idx].Index()]; prs {
continue
}
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match.item != nil {
bitmap[idx/64] |= uint64(1) << (idx % 64)
matches = append(matches, match)
}
}
return matches
return matches, bitmap
}
// MatchItem returns true if the Item is a match
func (p *Pattern) MatchItem(item *Item, withPos bool, slab *util.Slab) (*Result, []Offset, *[]int) {
// MatchItem returns the match result if the Item is a match.
// A zero-value Result (with item == nil) indicates no match.
func (p *Pattern) MatchItem(item *Item, withPos bool, slab *util.Slab) (Result, []Offset, *[]int) {
if p.extended {
if offsets, bonus, pos := p.extendedMatch(item, withPos, slab); len(offsets) == len(p.termSets) {
result := buildResult(item, offsets, bonus)
return &result, offsets, pos
return buildResult(item, offsets, bonus), offsets, pos
}
return nil, nil, nil
return Result{}, nil, nil
}
offset, bonus, pos := p.basicMatch(item, withPos, slab)
if sidx := offset[0]; sidx >= 0 {
offsets := []Offset{offset}
result := buildResult(item, offsets, bonus)
return &result, offsets, pos
return buildResult(item, offsets, bonus), offsets, pos
}
return nil, nil, nil
return Result{}, nil, nil
}
func (p *Pattern) basicMatch(item *Item, withPos bool, slab *util.Slab) (Offset, int, *[]int) {
+119 -2
View File
@@ -2,6 +2,7 @@ package fzf
import (
"reflect"
"runtime"
"testing"
"github.com/junegunn/fzf/src/algo"
@@ -68,7 +69,7 @@ func buildPattern(fuzzy bool, fuzzyAlgo algo.Algo, extended bool, caseMode Case,
withPos bool, cacheable bool, nth []Range, delimiter Delimiter, runes []rune) *Pattern {
return BuildPattern(NewChunkCache(), make(map[string]*Pattern),
fuzzy, fuzzyAlgo, extended, caseMode, normalize, forward,
withPos, cacheable, nth, delimiter, revision{}, runes, nil)
withPos, cacheable, nth, delimiter, revision{}, runes, nil, 0)
}
func TestExact(t *testing.T) {
@@ -137,7 +138,7 @@ func TestOrigTextAndTransformed(t *testing.T) {
origText: &origBytes,
transformed: &transformed{pattern.revision, trans}}
pattern.extended = extended
matches := pattern.matchChunk(&chunk, nil, slab) // No cache
matches, _ := pattern.matchChunk(&chunk, nil, slab) // No cache
if !(matches[0].item.text.ToString() == "junegunn" &&
string(*matches[0].item.origText) == "junegunn.choi" &&
reflect.DeepEqual((*matches[0].item.transformed).tokens, trans)) {
@@ -199,3 +200,119 @@ func TestCacheable(t *testing.T) {
test(false, "foo 'bar", "foo", false)
test(false, "foo !bar", "foo", false)
}
func buildChunks(numChunks int) []*Chunk {
chunks := make([]*Chunk, numChunks)
words := []string{
"src/main/java/com/example/service/UserService.java",
"src/test/java/com/example/service/UserServiceTest.java",
"docs/api/reference/endpoints.md",
"lib/internal/utils/string_helper.go",
"pkg/server/http/handler/auth.go",
"build/output/release/app.exe",
"config/production/database.yml",
"scripts/deploy/kubernetes/setup.sh",
"vendor/github.com/junegunn/fzf/src/core.go",
"node_modules/.cache/babel/transform.js",
}
for ci := range numChunks {
chunks[ci] = &Chunk{count: chunkSize}
for i := range chunkSize {
text := words[(ci*chunkSize+i)%len(words)]
chunks[ci].items[i] = Item{text: util.ToChars([]byte(text))}
chunks[ci].items[i].text.Index = int32(ci*chunkSize + i)
}
}
return chunks
}
func buildPatternWith(cache *ChunkCache, runes []rune) *Pattern {
return BuildPattern(cache, make(map[string]*Pattern),
true, algo.FuzzyMatchV2, true, CaseSmart, false, true,
false, true, []Range{}, Delimiter{}, revision{}, runes, nil, 0)
}
func TestBitmapCacheBenefit(t *testing.T) {
numChunks := 100
chunks := buildChunks(numChunks)
queries := []string{"s", "se", "ser", "serv", "servi"}
// 1. Run all queries with shared cache (simulates incremental typing)
cache := NewChunkCache()
for _, q := range queries {
pat := buildPatternWith(cache, []rune(q))
for _, chunk := range chunks {
pat.Match(chunk, slab)
}
}
// 2. GC and measure memory with cache populated
runtime.GC()
runtime.GC()
var memWith runtime.MemStats
runtime.ReadMemStats(&memWith)
// 3. Clear cache, GC, measure again
cache.Clear()
runtime.GC()
runtime.GC()
var memWithout runtime.MemStats
runtime.ReadMemStats(&memWithout)
cacheMem := int64(memWith.Alloc) - int64(memWithout.Alloc)
t.Logf("Chunks: %d, Queries: %d", numChunks, len(queries))
t.Logf("Cache memory: %d bytes (%.1f KB)", cacheMem, float64(cacheMem)/1024)
t.Logf("Per-chunk-per-query: %.0f bytes", float64(cacheMem)/float64(numChunks*len(queries)))
// 4. Verify correctness: cached vs uncached produce same results
cache2 := NewChunkCache()
for _, q := range queries {
pat := buildPatternWith(cache2, []rune(q))
for _, chunk := range chunks {
pat.Match(chunk, slab)
}
}
for _, q := range queries {
patCached := buildPatternWith(cache2, []rune(q))
patFresh := buildPatternWith(NewChunkCache(), []rune(q))
var countCached, countFresh int
for _, chunk := range chunks {
countCached += len(patCached.Match(chunk, slab))
countFresh += len(patFresh.Match(chunk, slab))
}
if countCached != countFresh {
t.Errorf("query=%q: cached=%d, fresh=%d", q, countCached, countFresh)
}
t.Logf("query=%q: matches=%d", q, countCached)
}
}
func BenchmarkWithCache(b *testing.B) {
numChunks := 100
chunks := buildChunks(numChunks)
queries := []string{"s", "se", "ser", "serv", "servi"}
b.Run("cached", func(b *testing.B) {
for range b.N {
cache := NewChunkCache()
for _, q := range queries {
pat := buildPatternWith(cache, []rune(q))
for _, chunk := range chunks {
pat.Match(chunk, slab)
}
}
}
})
b.Run("uncached", func(b *testing.B) {
for range b.N {
for _, q := range queries {
cache := NewChunkCache()
pat := buildPatternWith(cache, []rune(q))
for _, chunk := range chunks {
pat.Match(chunk, slab)
}
}
}
})
}
+1 -1
View File
@@ -6,5 +6,5 @@ import "golang.org/x/sys/unix"
// Protect calls OS specific protections like pledge on OpenBSD
func Protect() {
unix.PledgePromises("stdio dpath wpath rpath tty proc exec inet tmppath")
unix.PledgePromises("stdio cpath dpath wpath rpath inet fattr unix tty proc exec")
}
+26
View File
@@ -23,6 +23,32 @@ func escapeSingleQuote(str string) string {
return "'" + strings.ReplaceAll(str, "'", "'\\''") + "'"
}
func popupArgStr(args []string, opts *Options) (string, string) {
fzf, rest := args[0], args[1:]
args = []string{"--bind=ctrl-z:ignore"}
if !opts.Tmux.border && (opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine) {
if tui.DefaultBorderShape == tui.BorderRounded {
rest = append(rest, "--border=rounded")
} else {
rest = append(rest, "--border=sharp")
}
}
if opts.Tmux.border && opts.Margin == defaultMargin() {
args = append(args, "--margin=0,1")
}
argStr := escapeSingleQuote(fzf)
for _, arg := range append(args, rest...) {
argStr += " " + escapeSingleQuote(arg)
}
argStr += ` --no-popup --no-height`
dir, err := os.Getwd()
if err != nil {
dir = "."
}
return argStr, dir
}
func fifo(name string) (string, error) {
ns := time.Now().UnixNano()
output := filepath.Join(os.TempDir(), fmt.Sprintf("fzf-%s-%d", name, ns))
+49 -12
View File
@@ -7,6 +7,7 @@ import (
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"sync/atomic"
@@ -177,8 +178,8 @@ func (r *Reader) feed(src io.Reader) {
var err error
for {
n := 0
scope := slab[:util.Min(len(slab), readerBufferSize)]
for i := 0; i < 100; i++ {
scope := slab[:min(len(slab), readerBufferSize)]
for range 100 {
n, err = src.Read(scope)
if n > 0 || err != nil {
break
@@ -273,6 +274,24 @@ func (r *Reader) readFiles(roots []string, opts walkerOpts, ignores []string) bo
ToSlash: fastwalk.DefaultToSlash(),
Sort: fastwalk.SortFilesFirst,
}
// When following symlinks, precompute the absolute real paths of walker
// roots so we can skip symlinks that point to an ancestor. fastwalk's
// built-in loop detection (shouldTraverse) catches loops on the second
// pass, but a single pass through a symlink like z: -> / already
// traverses the entire root filesystem, causing severe resource
// exhaustion. Skipping ancestor symlinks prevents this entirely.
var absRoots []string
if opts.follow {
for _, root := range roots {
if real, err := filepath.EvalSymlinks(root); err == nil {
if abs, err := filepath.Abs(real); err == nil {
absRoots = append(absRoots, filepath.Clean(abs))
}
}
}
}
ignoresBase := []string{}
ignoresFull := []string{}
ignoresSuffix := []string{}
@@ -302,21 +321,39 @@ func (r *Reader) readFiles(roots []string, opts walkerOpts, ignores []string) bo
}
path = trimPath(path)
if path != "." {
isDir := de.IsDir()
if isDir || opts.follow && isSymlinkToDir(path, de) {
isDirSymlink := isSymlinkToDir(path, de)
if isDirSymlink && !opts.follow {
return filepath.SkipDir
}
// Skip symlinks whose target is an ancestor of (or equal to)
// any walker root. Following such symlinks would traverse a
// superset of the tree we're already walking.
if isDirSymlink && len(absRoots) > 0 {
if target, err := filepath.EvalSymlinks(path); err == nil {
if abs, err := filepath.Abs(target); err == nil {
abs = filepath.Clean(abs)
if abs == string(os.PathSeparator) {
return filepath.SkipDir
}
for _, absRoot := range absRoots {
if absRoot == abs || strings.HasPrefix(absRoot, abs+string(os.PathSeparator)) {
return filepath.SkipDir
}
}
}
}
}
isDir := de.IsDir() || isDirSymlink
if isDir {
base := filepath.Base(path)
if !opts.hidden && base[0] == '.' && base != ".." {
return filepath.SkipDir
}
for _, ignore := range ignoresBase {
if ignore == base {
return filepath.SkipDir
}
if slices.Contains(ignoresBase, base) {
return filepath.SkipDir
}
for _, ignore := range ignoresFull {
if ignore == path {
return filepath.SkipDir
}
if slices.Contains(ignoresFull, path) {
return filepath.SkipDir
}
for _, ignore := range ignoresSuffix {
if strings.HasSuffix(path, ignore) {
+126 -30
View File
@@ -2,6 +2,7 @@ package fzf
import (
"math"
"slices"
"sort"
"unicode"
@@ -30,11 +31,9 @@ type Result struct {
func buildResult(item *Item, offsets []Offset, score int) Result {
if len(offsets) > 1 {
sort.Sort(ByOrder(offsets))
slices.SortFunc(offsets, compareOffsets)
}
result := Result{item: item}
numChars := item.text.Length()
minBegin := math.MaxUint16
minEnd := math.MaxUint16
maxEnd := 0
@@ -42,13 +41,21 @@ func buildResult(item *Item, offsets []Offset, score int) Result {
for _, offset := range offsets {
b, e := int(offset[0]), int(offset[1])
if b < e {
minBegin = util.Min(b, minBegin)
minEnd = util.Min(e, minEnd)
maxEnd = util.Max(e, maxEnd)
minBegin = min(b, minBegin)
minEnd = min(e, minEnd)
maxEnd = max(e, maxEnd)
validOffsetFound = true
}
}
return buildResultFromBounds(item, score, minBegin, minEnd, maxEnd, validOffsetFound)
}
// buildResultFromBounds builds a Result from pre-computed offset bounds.
func buildResultFromBounds(item *Item, score int, minBegin, minEnd, maxEnd int, validOffsetFound bool) Result {
result := Result{item: item}
numChars := item.text.Length()
for idx, criterion := range sortCriteria {
val := uint16(math.MaxUint16)
switch criterion {
@@ -75,7 +82,6 @@ func buildResult(item *Item, offsets []Offset, score int) Result {
val = item.TrimLength()
case byPathname:
if validOffsetFound {
// lastDelim := strings.LastIndexByte(item.text.ToString(), '/')
lastDelim := -1
s := item.text.ToString()
for i := len(s) - 1; i >= 0; i-- {
@@ -91,7 +97,7 @@ func buildResult(item *Item, offsets []Offset, score int) Result {
case byBegin, byEnd:
if validOffsetFound {
whitePrefixLen := 0
for idx := 0; idx < numChars; idx++ {
for idx := range numChars {
r := item.text.Get(idx)
whitePrefixLen = idx
if idx == minBegin || !unicode.IsSpace(r) {
@@ -123,7 +129,7 @@ func minRank() Result {
return Result{item: &minItem, points: [4]uint16{math.MaxUint16, 0, 0, 0}}
}
func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, theme *tui.ColorTheme, colBase tui.ColorPair, colMatch tui.ColorPair, attrNth tui.Attr) []colorOffset {
func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, theme *tui.ColorTheme, colBase tui.ColorPair, colMatch tui.ColorPair, attrNth tui.Attr, nthOverlay tui.Attr, hidden bool) []colorOffset {
itemColors := result.item.Colors()
// No ANSI codes
@@ -182,7 +188,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
}
}
// sort.Sort(ByOrder(offsets))
// slices.SortFunc(offsets, compareOffsets)
// Merge offsets
// ------------ ---- -- ----
@@ -194,6 +200,10 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
if !theme.Colored {
return tui.NewColorPair(-1, -1, ansi.color.attr).MergeAttr(base)
}
// fd --color always | fzf --ansi --delimiter / --nth -1 --color fg:dim:strip,nth:regular
if base.ShouldStripColors() {
return base
}
fg := ansi.color.fg
bg := ansi.color.bg
if fg == -1 {
@@ -202,8 +212,12 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
if bg == -1 {
bg = colBase.Bg()
}
return tui.NewColorPair(fg, bg, ansi.color.attr).MergeAttr(base)
return tui.NewColorPair(fg, bg, ansi.color.attr).WithUl(ansi.color.ul).MergeAttr(base)
}
fgAttr := tui.ColNormal.Attr()
nthAttrFinal := fgAttr.Merge(attrNth).Merge(nthOverlay)
nthBase := colBase.WithNewAttr(nthAttrFinal)
var colors []colorOffset
add := func(idx int) {
if curr.fbg >= 0 {
@@ -217,7 +231,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
if curr.match {
var color tui.ColorPair
if curr.nth {
color = colBase.WithAttr(attrNth).Merge(colMatch)
color = nthBase.Merge(colMatch)
} else {
color = colBase.Merge(colMatch)
}
@@ -237,7 +251,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
if color.Fg().IsDefault() && origColor.HasBg() {
color = origColor
if curr.nth {
color = color.WithAttr(attrNth &^ tui.AttrRegular)
color = color.WithAttr((attrNth &^ tui.AttrRegular).Merge(nthOverlay))
}
} else {
color = origColor.MergeNonDefault(color)
@@ -249,7 +263,10 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
ansi := itemColors[curr.index]
base := colBase
if curr.nth {
base = base.WithAttr(attrNth)
base = nthBase
}
if hidden {
base = base.WithFg(theme.Nomatch)
}
color := ansiToColorPair(ansi, base)
colors = append(colors, colorOffset{
@@ -258,9 +275,13 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
match: false,
url: ansi.color.url})
} else {
color := nthBase
if hidden {
color = color.WithFg(theme.Nomatch)
}
colors = append(colors, colorOffset{
offset: [2]int32{int32(start), int32(idx)},
color: colBase.WithAttr(attrNth),
color: color,
match: false,
url: nil})
}
@@ -277,21 +298,20 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
return colors
}
// ByOrder is for sorting substring offsets
type ByOrder []Offset
func (a ByOrder) Len() int {
return len(a)
}
func (a ByOrder) Swap(i, j int) {
a[i], a[j] = a[j], a[i]
}
func (a ByOrder) Less(i, j int) bool {
ioff := a[i]
joff := a[j]
return (ioff[0] < joff[0]) || (ioff[0] == joff[0]) && (ioff[1] <= joff[1])
func compareOffsets(a, b Offset) int {
if a[0] < b[0] {
return -1
}
if a[0] > b[0] {
return 1
}
if a[1] < b[1] {
return -1
}
if a[1] > b[1] {
return 1
}
return 0
}
// ByRelevance is for sorting Items
@@ -323,3 +343,79 @@ func (a ByRelevanceTac) Swap(i, j int) {
func (a ByRelevanceTac) Less(i, j int) bool {
return compareRanks(a[i], a[j], true)
}
// radixSortResults sorts Results by their points key using LSD radix sort.
// O(n) time complexity vs O(n log n) for comparison sort.
// The sort is stable, so equal-key items maintain original (item-index) order.
// For tac mode, runs of equal keys are reversed after sorting.
func radixSortResults(a []Result, tac bool, scratch []Result) []Result {
n := len(a)
if n < 128 {
if tac {
sort.Sort(ByRelevanceTac(a))
} else {
sort.Sort(ByRelevance(a))
}
return scratch[:0]
}
if cap(scratch) < n {
scratch = make([]Result, n)
}
buf := scratch[:n]
src, dst := a, buf
scattered := 0
for pass := range 8 {
shift := uint(pass) * 8
var count [256]int
for i := range src {
count[byte(sortKey(&src[i])>>shift)]++
}
// Skip if all items have the same byte value at this position
if count[byte(sortKey(&src[0])>>shift)] == n {
continue
}
var offset [256]int
for i := 1; i < 256; i++ {
offset[i] = offset[i-1] + count[i-1]
}
for i := range src {
b := byte(sortKey(&src[i]) >> shift)
dst[offset[b]] = src[i]
offset[b]++
}
src, dst = dst, src
scattered++
}
// If odd number of scatters, data is in buf, copy back to a
if scattered%2 == 1 {
copy(a, src)
}
// Handle tac: reverse runs of equal keys so equal-key items
// are in reverse item-index order
if tac {
i := 0
for i < n {
ki := sortKey(&a[i])
j := i + 1
for j < n && sortKey(&a[j]) == ki {
j++
}
if j-i > 1 {
for l, r := i, j-1; l < r; l, r = l+1, r-1 {
a[l], a[r] = a[r], a[l]
}
}
i = j
}
}
return scratch
}
+5 -1
View File
@@ -1,4 +1,4 @@
//go:build !386 && !amd64
//go:build !386 && !amd64 && !arm64
package fzf
@@ -14,3 +14,7 @@ func compareRanks(irank Result, jrank Result, tac bool) bool {
}
return (irank.item.Index() <= jrank.item.Index()) != tac
}
func sortKey(r *Result) uint64 {
return uint64(r.points[0]) | uint64(r.points[1])<<16 | uint64(r.points[2])<<32 | uint64(r.points[3])<<48
}
+97 -7
View File
@@ -2,6 +2,8 @@ package fzf
import (
"math"
"math/rand"
"slices"
"sort"
"testing"
@@ -18,7 +20,7 @@ func TestOffsetSort(t *testing.T) {
offsets := []Offset{
{3, 5}, {2, 7},
{1, 3}, {2, 9}}
sort.Sort(ByOrder(offsets))
slices.SortFunc(offsets, compareOffsets)
if offsets[0][0] != 1 || offsets[0][1] != 3 ||
offsets[1][0] != 2 || offsets[1][1] != 7 ||
@@ -124,14 +126,14 @@ func TestColorOffset(t *testing.T) {
item := Result{
item: &Item{
colors: &[]ansiOffset{
{[2]int32{0, 20}, ansiState{1, 5, 0, -1, nil}},
{[2]int32{22, 27}, ansiState{2, 6, tui.Bold, -1, nil}},
{[2]int32{30, 32}, ansiState{3, 7, 0, -1, nil}},
{[2]int32{33, 40}, ansiState{4, 8, tui.Bold, -1, nil}}}}}
{[2]int32{0, 20}, ansiState{1, 5, -1, 0, -1, nil}},
{[2]int32{22, 27}, ansiState{2, 6, -1, tui.Bold, -1, nil}},
{[2]int32{30, 32}, ansiState{3, 7, -1, 0, -1, nil}},
{[2]int32{33, 40}, ansiState{4, 8, -1, tui.Bold, -1, nil}}}}}
colBase := tui.NewColorPair(89, 189, tui.AttrUndefined)
colMatch := tui.NewColorPair(99, 199, tui.AttrUndefined)
colors := item.colorOffsets(offsets, nil, tui.Dark256, colBase, colMatch, tui.AttrUndefined)
colors := item.colorOffsets(offsets, nil, tui.Dark256, colBase, colMatch, tui.AttrUndefined, 0, false)
assert := func(idx int, b int32, e int32, c tui.ColorPair) {
o := colors[idx]
if o.offset[0] != b || o.offset[1] != e || o.color != c {
@@ -158,7 +160,7 @@ func TestColorOffset(t *testing.T) {
nthOffsets := []Offset{{37, 39}, {42, 45}}
for _, attr := range []tui.Attr{tui.AttrRegular, tui.StrikeThrough} {
colors = item.colorOffsets(offsets, nthOffsets, tui.Dark256, colRegular, colUnderline, attr)
colors = item.colorOffsets(offsets, nthOffsets, tui.Dark256, colRegular, colUnderline, attr, 0, false)
// [{[0 5] {1 5 0}} {[5 15] {1 5 8}} {[15 20] {1 5 0}}
// {[22 25] {2 6 1}} {[25 27] {2 6 9}} {[27 30] {-1 -1 8}}
@@ -181,4 +183,92 @@ func TestColorOffset(t *testing.T) {
assert(10, 37, 39, tui.NewColorPair(4, 8, expected))
assert(11, 39, 40, tui.NewColorPair(4, 8, tui.Bold))
}
// Test nthOverlay: simulates nth:regular with current-fg:underline
// The overlay (underline) should survive even though nth:regular clears attrs.
// Precedence: fg < nth < current-fg
colors = item.colorOffsets(offsets, nthOffsets, tui.Dark256, colRegular, colUnderline, tui.AttrRegular, tui.Underline, false)
// nth regions should have Underline (from overlay), not cleared by AttrRegular
// Non-nth regions keep colBase attrs (AttrUndefined)
assert(0, 0, 5, tui.NewColorPair(1, 5, tui.AttrUndefined))
assert(1, 5, 15, tui.NewColorPair(1, 5, tui.Underline))
assert(2, 15, 20, tui.NewColorPair(1, 5, tui.AttrUndefined))
assert(3, 22, 25, tui.NewColorPair(2, 6, tui.Bold))
assert(4, 25, 27, tui.NewColorPair(2, 6, tui.Bold|tui.Underline))
assert(5, 27, 30, colUnderline)
assert(6, 30, 32, tui.NewColorPair(3, 7, tui.Underline))
assert(7, 32, 33, colUnderline)
assert(8, 33, 35, tui.NewColorPair(4, 8, tui.Bold|tui.Underline))
assert(9, 35, 37, tui.NewColorPair(4, 8, tui.Bold))
// nth region within ANSI bold: AttrRegular clears, overlay adds Underline back
assert(10, 37, 39, tui.NewColorPair(4, 8, tui.Bold|tui.Underline))
assert(11, 39, 40, tui.NewColorPair(4, 8, tui.Bold))
// Test nthOverlay with additive attrs: nth:strikethrough with selected-fg:bold
colors = item.colorOffsets(offsets, nthOffsets, tui.Dark256, colRegular, colUnderline, tui.StrikeThrough, tui.Bold, false)
// Non-nth entries unchanged from overlay=0 case
assert(0, 0, 5, tui.NewColorPair(1, 5, tui.AttrUndefined))
assert(5, 27, 30, colUnderline) // match only, no nth
assert(7, 32, 33, colUnderline) // match only, no nth
// nth region within ANSI bold: StrikeThrough|Bold merged with ANSI Bold
assert(10, 37, 39, tui.NewColorPair(4, 8, tui.Bold|tui.StrikeThrough))
}
func TestRadixSortResults(t *testing.T) {
sortCriteria = []criterion{byScore, byLength}
rng := rand.New(rand.NewSource(42))
for _, n := range []int{128, 256, 500, 1000} {
for _, tac := range []bool{false, true} {
// Build items with random points and indices
items := make([]*Item, n)
for i := range items {
items[i] = &Item{text: util.Chars{Index: int32(i)}}
}
results := make([]Result, n)
for i := range results {
results[i] = Result{
item: items[i],
points: [4]uint16{
uint16(rng.Intn(256)),
uint16(rng.Intn(256)),
uint16(rng.Intn(256)),
uint16(rng.Intn(256)),
},
}
}
// Make some duplicates to test stability
for i := 0; i < n/4; i++ {
j := rng.Intn(n)
k := rng.Intn(n)
results[j].points = results[k].points
}
// Copy for reference sort
expected := make([]Result, n)
copy(expected, results)
if tac {
sort.Sort(ByRelevanceTac(expected))
} else {
sort.Sort(ByRelevance(expected))
}
// Radix sort
var scratch []Result
scratch = radixSortResults(results, tac, scratch)
for i := range results {
if results[i] != expected[i] {
t.Errorf("n=%d tac=%v: mismatch at index %d: got item %d, want item %d",
n, tac, i, results[i].item.Index(), expected[i].item.Index())
break
}
}
}
}
}
+5 -1
View File
@@ -1,4 +1,4 @@
//go:build 386 || amd64
//go:build 386 || amd64 || arm64
package fzf
@@ -14,3 +14,7 @@ func compareRanks(irank Result, jrank Result, tac bool) bool {
}
return (irank.item.Index() <= jrank.item.Index()) != tac
}
func sortKey(r *Result) uint64 {
return *(*uint64)(unsafe.Pointer(&r.points[0]))
}
+66 -36
View File
@@ -46,15 +46,20 @@ type httpServer struct {
type listenAddress struct {
host string
port int
sock string
}
func (addr listenAddress) IsLocal() bool {
return addr.host == "localhost" || addr.host == "127.0.0.1"
return addr.host == "localhost" || addr.host == "127.0.0.1" || len(addr.sock) > 0
}
var defaultListenAddr = listenAddress{"localhost", 0}
var defaultListenAddr = listenAddress{"localhost", 0, ""}
func parseListenAddress(address string) (listenAddress, error) {
if strings.HasSuffix(address, ".sock") {
return listenAddress{"", 0, address}, nil
}
parts := strings.SplitN(address, ":", 3)
if len(parts) == 1 {
parts = []string{"localhost", parts[0]}
@@ -70,7 +75,7 @@ func parseListenAddress(address string) (listenAddress, error) {
if len(parts[0]) == 0 {
parts[0] = "localhost"
}
return listenAddress{parts[0], port}, nil
return listenAddress{parts[0], port, ""}, nil
}
func startHttpServer(address listenAddress, actionChannel chan []*action, getHandler func(getParams) string) (net.Listener, int, error) {
@@ -80,31 +85,49 @@ func startHttpServer(address listenAddress, actionChannel chan []*action, getHan
if !address.IsLocal() && len(apiKey) == 0 {
return nil, port, errors.New("FZF_API_KEY is required to allow remote access")
}
addrStr := fmt.Sprintf("%s:%d", host, port)
listener, err := net.Listen("tcp", addrStr)
if err != nil {
return nil, port, fmt.Errorf("failed to listen on %s", addrStr)
}
if port == 0 {
addr := listener.Addr().String()
parts := strings.Split(addr, ":")
if len(parts) < 2 {
return nil, port, fmt.Errorf("cannot extract port: %s", addr)
}
var err error
port, err = strconv.Atoi(parts[len(parts)-1])
if err != nil {
return nil, port, err
}
}
server := httpServer{
apiKey: []byte(apiKey),
actionChannel: actionChannel,
getHandler: getHandler,
var listener net.Listener
var err error
if len(address.sock) > 0 {
if _, err := os.Stat(address.sock); err == nil {
// Check if the socket is already in use
if conn, err := net.Dial("unix", address.sock); err == nil {
conn.Close()
return nil, 0, fmt.Errorf("socket already in use: %s", address.sock)
}
os.Remove(address.sock)
}
listener, err = net.Listen("unix", address.sock)
if err != nil {
return nil, 0, fmt.Errorf("failed to listen on %s", address.sock)
}
os.Chmod(address.sock, 0600)
} else {
addrStr := fmt.Sprintf("%s:%d", host, port)
listener, err = net.Listen("tcp", addrStr)
if err != nil {
return nil, port, fmt.Errorf("failed to listen on %s", addrStr)
}
if port == 0 {
addr := listener.Addr().String()
parts := strings.Split(addr, ":")
if len(parts) < 2 {
return nil, port, fmt.Errorf("cannot extract port: %s", addr)
}
var err error
port, err = strconv.Atoi(parts[len(parts)-1])
if err != nil {
return nil, port, err
}
}
}
go func() {
server := httpServer{
apiKey: []byte(apiKey),
actionChannel: actionChannel,
getHandler: getHandler,
}
for {
conn, err := listener.Accept()
if err != nil {
@@ -159,23 +182,22 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
})
section := 0
var getMatch []string
Loop:
for scanner.Scan() {
text := scanner.Text()
switch section {
case 0:
getMatch := getRegex.FindStringSubmatch(text)
if len(getMatch) > 0 {
response := server.getHandler(parseGetParams(getMatch[1]))
if len(response) > 0 {
return good(response)
}
return answer(httpUnavailable+jsonContentType, `{"error":"timeout"}`)
} else if !strings.HasPrefix(text, "POST / HTTP") {
case 0: // Request line
getMatch = getRegex.FindStringSubmatch(text)
if len(getMatch) == 0 && !strings.HasPrefix(text, "POST / HTTP") {
return bad("invalid request method")
}
section++
case 1:
if text == crlf {
case 1: // Request headers
if text == crlf { // End of headers
if len(getMatch) > 0 {
break Loop
}
if contentLength == 0 {
return bad("content-length header missing")
}
@@ -195,7 +217,7 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
apiKey = strings.TrimSpace(pair[1])
}
}
case 2:
case 2: // Request body
body += text
}
}
@@ -204,6 +226,14 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
return unauthorized("invalid api key")
}
if len(getMatch) > 0 {
response := server.getHandler(parseGetParams(getMatch[1]))
if len(response) > 0 {
return good(response)
}
return answer(httpUnavailable+jsonContentType, `{"error":"timeout"}`)
}
if len(body) < contentLength {
return bad("incomplete request")
}
+1686 -559
View File
File diff suppressed because it is too large Load Diff
+69
View File
@@ -699,3 +699,72 @@ func readFile(path string) ([]byte, error) {
}
}
}
func TestWordWrapAnsiLine(t *testing.T) {
term := &Terminal{}
// Simple wrapping
result := term.wordWrapAnsiLine("hello world", 7, 2)
if len(result) != 2 || result[0] != "hello" || result[1] != "world" {
t.Errorf("Simple: %q", result)
}
// No wrapping needed
result = term.wordWrapAnsiLine("hello", 10, 2)
if len(result) != 1 || result[0] != "hello" {
t.Errorf("No wrap: %q", result)
}
// ANSI codes preserved across split
result = term.wordWrapAnsiLine("\x1b[31mhello \x1b[32mworld", 8, 2)
if len(result) != 2 || result[0] != "\x1b[31mhello" || result[1] != "\x1b[32mworld" {
t.Errorf("ANSI: %q", result)
}
// Long word (no space) - no break, let character wrapping handle it
result = term.wordWrapAnsiLine("abcdefghij", 5, 2)
if len(result) != 1 || result[0] != "abcdefghij" {
t.Errorf("Long word: %q", result)
}
// Multiple words with continuation wrapSignWidth
result = term.wordWrapAnsiLine("aa bb cc dd", 5, 2)
// max=5 for first line, max=3 for continuations (5-2)
// "aa bb" (5 wide), split at second space -> "aa bb" | "cc" | "dd"
if len(result) != 3 || result[0] != "aa bb" || result[1] != "cc" || result[2] != "dd" {
t.Errorf("Multiple words: %q", result)
}
// Empty string
result = term.wordWrapAnsiLine("", 10, 2)
if len(result) != 1 || result[0] != "" {
t.Errorf("Empty: %q", result)
}
// OSC 8 hyperlink preserved
result = term.wordWrapAnsiLine("\x1b]8;;http://example.com\x1b\\click here\x1b]8;;\x1b\\", 8, 2)
if len(result) != 2 {
t.Errorf("Hyperlink split count: %d, %q", len(result), result)
}
// Tab handling: tab expands to tabstop-aligned width
term.tabstop = 8
// "\thi there" - tab at column 0 expands to 8, total "hi" starts at 8
// maxWidth=15: "\thi" = 10 wide, "there" = 5 wide, total 16 > 15, wrap at space
result = term.wordWrapAnsiLine("\thi there", 15, 2)
if len(result) != 2 || result[0] != "\thi" || result[1] != "there" {
t.Errorf("Tab: %q", result)
}
// Tab as word boundary: "hello"(5) + tab(3→col8) + "world"(5) = 13 total
// maxWidth=13: fits without wrapping
result = term.wordWrapAnsiLine("hello\tworld", 13, 2)
if len(result) != 1 || result[0] != "hello\tworld" {
t.Errorf("Tab no wrap: %q", result)
}
// maxWidth=12: 13 > 12, wraps at tab
result = term.wordWrapAnsiLine("hello\tworld", 12, 2)
if len(result) != 2 || result[0] != "hello" || result[1] != "world" {
t.Errorf("Tab wrap: %q", result)
}
}
+1 -29
View File
@@ -1,39 +1,11 @@
package fzf
import (
"os"
"os/exec"
"github.com/junegunn/fzf/src/tui"
)
func runTmux(args []string, opts *Options) (int, error) {
// Prepare arguments
fzf, rest := args[0], args[1:]
args = []string{"--bind=ctrl-z:ignore"}
if !opts.Tmux.border && (opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine) {
// We append --border option at the end, because `--style=full:STYLE`
// may have changed the default border style.
if tui.DefaultBorderShape == tui.BorderRounded {
rest = append(rest, "--border=rounded")
} else {
rest = append(rest, "--border=sharp")
}
}
if opts.Tmux.border && opts.Margin == defaultMargin() {
args = append(args, "--margin=0,1")
}
argStr := escapeSingleQuote(fzf)
for _, arg := range append(args, rest...) {
argStr += " " + escapeSingleQuote(arg)
}
argStr += ` --no-tmux --no-height`
// Get current directory
dir, err := os.Getwd()
if err != nil {
dir = "."
}
argStr, dir := popupArgStr(args, opts)
// Set tmux options for popup placement
// C Both The centre of the terminal
+27 -7
View File
@@ -161,7 +161,7 @@ func awkTokenizer(input string) ([]string, int) {
end := 0
for idx := 0; idx < len(input); idx++ {
r := input[idx]
white := r == 9 || r == 32
white := r == 9 || r == 32 || r == 10
switch state {
case awkNil:
if white {
@@ -206,8 +206,9 @@ func Tokenize(text string, delimiter Delimiter) []Token {
if delimiter.regex != nil {
locs := delimiter.regex.FindAllStringIndex(text, -1)
begin := 0
for _, loc := range locs {
tokens = append(tokens, text[begin:loc[1]])
tokens = make([]string, len(locs))
for i, loc := range locs {
tokens[i] = text[begin:loc[1]]
begin = loc[1]
}
if begin < len(text) {
@@ -217,11 +218,12 @@ func Tokenize(text string, delimiter Delimiter) []Token {
return withPrefixLengths(tokens, 0)
}
// StripLastDelimiter removes the trailing delimiter and whitespaces
// StripLastDelimiter removes the trailing delimiter
func StripLastDelimiter(str string, delimiter Delimiter) string {
if delimiter.str != nil {
str = strings.TrimSuffix(str, *delimiter.str)
} else if delimiter.regex != nil {
return strings.TrimSuffix(str, *delimiter.str)
}
if delimiter.regex != nil {
locs := delimiter.regex.FindAllStringIndex(str, -1)
if len(locs) > 0 {
lastLoc := locs[len(locs)-1]
@@ -229,10 +231,28 @@ func StripLastDelimiter(str string, delimiter Delimiter) string {
str = str[:lastLoc[0]]
}
}
return str
}
return strings.TrimRightFunc(str, unicode.IsSpace)
}
func GetLastDelimiter(str string, delimiter Delimiter) string {
if delimiter.str != nil {
if strings.HasSuffix(str, *delimiter.str) {
return *delimiter.str
}
} else if delimiter.regex != nil {
locs := delimiter.regex.FindAllStringIndex(str, -1)
if len(locs) > 0 {
lastLoc := locs[len(locs)-1]
if lastLoc[1] == len(str) {
return str[lastLoc[0]:]
}
}
}
return ""
}
// JoinTokens concatenates the tokens into a single string
func JoinTokens(tokens []Token) string {
var output bytes.Buffer
@@ -284,7 +304,7 @@ func Transform(tokens []Token, withNth []Range) []Token {
end += numTokens + 1
}
}
minIdx = util.Max(0, begin-1)
minIdx = max(0, begin-1)
for idx := begin; idx <= end; idx++ {
if idx >= 1 && idx <= numTokens {
parts = append(parts, tokens[idx-1].text)
+5 -5
View File
@@ -56,9 +56,9 @@ func TestParseRange(t *testing.T) {
func TestTokenize(t *testing.T) {
// AWK-style
input := " abc: def: ghi "
input := " abc: \n\t def: ghi "
tokens := Tokenize(input, Delimiter{})
if tokens[0].text.ToString() != "abc: " || tokens[0].prefixLength != 2 {
if tokens[0].text.ToString() != "abc: \n\t " || tokens[0].prefixLength != 2 {
t.Errorf("%s", tokens)
}
@@ -71,9 +71,9 @@ func TestTokenize(t *testing.T) {
// With delimiter regex
tokens = Tokenize(input, delimiterRegexp("\\s+"))
if tokens[0].text.ToString() != " " || tokens[0].prefixLength != 0 ||
tokens[1].text.ToString() != "abc: " || tokens[1].prefixLength != 2 ||
tokens[2].text.ToString() != "def: " || tokens[2].prefixLength != 8 ||
tokens[3].text.ToString() != "ghi " || tokens[3].prefixLength != 14 {
tokens[1].text.ToString() != "abc: \n\t " || tokens[1].prefixLength != 2 ||
tokens[2].text.ToString() != "def: " || tokens[2].prefixLength != 10 ||
tokens[3].text.ToString() != "ghi " || tokens[3].prefixLength != 16 {
t.Errorf("%s", tokens)
}
}
+11 -28
View File
@@ -2,30 +2,7 @@
package tui
type Attr int32
func HasFullscreenRenderer() bool {
return false
}
var DefaultBorderShape = BorderRounded
func (a Attr) Merge(b Attr) Attr {
if b&AttrRegular > 0 {
// Only keep bold attribute set by the system
return (b &^ AttrRegular) | (a & BoldForce)
}
return (a &^ AttrRegular) | b
}
const (
AttrUndefined = Attr(0)
AttrRegular = Attr(1 << 8)
AttrClear = Attr(1 << 9)
BoldForce = Attr(1 << 10)
FullBg = Attr(1 << 11)
Bold = Attr(1)
Dim = Attr(1 << 1)
Italic = Attr(1 << 2)
@@ -36,6 +13,12 @@ const (
StrikeThrough = Attr(1 << 7)
)
func HasFullscreenRenderer() bool {
return false
}
var DefaultBorderShape = BorderRounded
func (r *FullscreenRenderer) Init() error { return nil }
func (r *FullscreenRenderer) DefaultTheme() *ColorTheme { return nil }
func (r *FullscreenRenderer) Resize(maxHeightFunc func(int) int) {}
@@ -51,11 +34,11 @@ func (r *FullscreenRenderer) ShowCursor() {}
func (r *FullscreenRenderer) Refresh() {}
func (r *FullscreenRenderer) Close() {}
func (r *FullscreenRenderer) Size() TermSize { return TermSize{} }
func (r *FullscreenRenderer) GetChar() Event { return Event{} }
func (r *FullscreenRenderer) Top() int { return 0 }
func (r *FullscreenRenderer) MaxX() int { return 0 }
func (r *FullscreenRenderer) MaxY() int { return 0 }
func (r *FullscreenRenderer) Top() int { return 0 }
func (r *FullscreenRenderer) MaxX() int { return 0 }
func (r *FullscreenRenderer) MaxY() int { return 0 }
func (r *FullscreenRenderer) GetChar(bool) Event { return Event{} }
func (r *FullscreenRenderer) CancelGetChar() {}
func (r *FullscreenRenderer) RefreshWindows(windows []Window) {}
+240 -181
View File
@@ -13,7 +13,6 @@ import (
"unicode/utf8"
"github.com/junegunn/fzf/src/util"
"github.com/rivo/uniseg"
"golang.org/x/term"
)
@@ -26,6 +25,7 @@ const (
escPollInterval = 5
offsetPollTries = 10
maxInputBuffer = 1024 * 1024
maxSelectTries = 100
)
const DefaultTtyDevice string = "/dev/tty"
@@ -49,6 +49,18 @@ const DIM string = "\x1b[2m"
const CR string = DIM + "␍"
const LF string = DIM + "␊"
type getCharResult int
const (
getCharSuccess getCharResult = iota
getCharError
getCharCancelled
)
func (r getCharResult) ok() bool {
return r == getCharSuccess
}
func (r *LightRenderer) stderrInternal(str string, allowNLCR bool, resetCode string) {
bytes := []byte(str)
runes := []rune{}
@@ -104,6 +116,7 @@ type LightRenderer struct {
clicks [][2]int
ttyin *os.File
ttyout *os.File
cancel func()
buffer []byte
origState *term.State
width int
@@ -118,9 +131,9 @@ type LightRenderer struct {
x int
maxHeightFunc func(int) int
showCursor bool
mutex sync.Mutex
// Windows only
mutex sync.Mutex
ttyinChannel chan byte
inHandle uintptr
outHandle uintptr
@@ -193,11 +206,6 @@ func (r *LightRenderer) Init() error {
if r.fullscreen {
r.smcup()
} else {
// We assume that --no-clear is used for repetitive relaunching of fzf.
// So we do not clear the lower bottom of the screen.
if r.clearOnExit {
r.csi("J")
}
y, x := r.findOffset()
r.mouse = r.mouse && y >= 0
// When --no-clear is used for repetitive relaunching, there is a small
@@ -208,6 +216,11 @@ func (r *LightRenderer) Init() error {
r.upOneLine = true
r.makeSpace()
}
// We assume that --no-clear is used for repetitive relaunching of fzf.
// So we do not clear the lower bottom of the screen.
if r.clearOnExit {
r.csi("J")
}
for i := 1; i < r.MaxY(); i++ {
r.makeSpace()
}
@@ -262,16 +275,18 @@ func getEnv(name string, defaultValue int) int {
return atoi(env, defaultValue)
}
func (r *LightRenderer) getBytes() ([]byte, error) {
bytes, err := r.getBytesInternal(r.buffer, false)
return bytes, err
func (r *LightRenderer) getBytes(cancellable bool) ([]byte, getCharResult, error) {
return r.getBytesInternal(cancellable, r.buffer, false)
}
func (r *LightRenderer) getBytesInternal(buffer []byte, nonblock bool) ([]byte, error) {
c, ok := r.getch(nonblock)
if !nonblock && !ok {
func (r *LightRenderer) getBytesInternal(cancellable bool, buffer []byte, nonblock bool) ([]byte, getCharResult, error) {
c, result := r.getch(cancellable, nonblock)
if result == getCharCancelled {
return buffer, getCharCancelled, nil
}
if !nonblock && !result.ok() {
r.Close()
return nil, errors.New("failed to read " + DefaultTtyDevice)
return nil, getCharError, errors.New("failed to read " + DefaultTtyDevice)
}
retries := 0
@@ -282,8 +297,8 @@ func (r *LightRenderer) getBytesInternal(buffer []byte, nonblock bool) ([]byte,
pc := c
for {
c, ok = r.getch(true)
if !ok {
c, result = r.getch(false, true)
if !result.ok() {
if retries > 0 {
retries--
time.Sleep(escPollInterval * time.Millisecond)
@@ -302,20 +317,24 @@ func (r *LightRenderer) getBytesInternal(buffer []byte, nonblock bool) ([]byte,
// so terminate fzf immediately.
if len(buffer) > maxInputBuffer {
r.Close()
return nil, fmt.Errorf("input buffer overflow (%d): %v", len(buffer), buffer)
return nil, getCharError, fmt.Errorf("input buffer overflow (%d): %v", len(buffer), buffer)
}
}
return buffer, nil
return buffer, getCharSuccess, nil
}
func (r *LightRenderer) GetChar() Event {
func (r *LightRenderer) GetChar(cancellable bool) Event {
var err error
var result getCharResult
if len(r.buffer) == 0 {
r.buffer, err = r.getBytes()
r.buffer, result, err = r.getBytes(cancellable)
if err != nil {
return Event{Fatal, 0, nil}
}
if result == getCharCancelled {
return Event{Invalid, 0, nil}
}
}
if len(r.buffer) == 0 {
return Event{Fatal, 0, nil}
@@ -351,9 +370,14 @@ func (r *LightRenderer) GetChar() Event {
ev := r.escSequence(&sz)
// Second chance
if ev.Type == Invalid {
if r.buffer, err = r.getBytes(); err != nil {
r.buffer, result, err = r.getBytes(true)
if err != nil {
return Event{Fatal, 0, nil}
}
if result == getCharCancelled {
return Event{Invalid, 0, nil}
}
ev = r.escSequence(&sz)
}
return ev
@@ -371,6 +395,21 @@ func (r *LightRenderer) GetChar() Event {
return Event{Rune, char, nil}
}
func (r *LightRenderer) CancelGetChar() {
r.mutex.Lock()
if r.cancel != nil {
r.cancel()
r.cancel = nil
}
r.mutex.Unlock()
}
func (r *LightRenderer) setCancel(f func()) {
r.mutex.Lock()
r.cancel = f
r.mutex.Unlock()
}
func (r *LightRenderer) escSequence(sz *int) Event {
if len(r.buffer) < 2 {
return Event{Esc, 0, nil}
@@ -479,6 +518,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
return Event{Delete, 0, nil}
}
if len(r.buffer) == 7 && r.buffer[6] == '~' && r.buffer[4] == '1' {
*sz = 7
switch r.buffer[5] {
case '0':
return Event{AltShiftDelete, 0, nil}
@@ -525,6 +565,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
return Event{PageUp, 0, nil}
}
if len(r.buffer) == 7 && r.buffer[6] == '~' && r.buffer[4] == '1' {
*sz = 7
switch r.buffer[5] {
case '0':
return Event{AltShiftPageUp, 0, nil}
@@ -569,6 +610,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
return Event{PageDown, 0, nil}
}
if len(r.buffer) == 7 && r.buffer[6] == '~' && r.buffer[4] == '1' {
*sz = 7
switch r.buffer[5] {
case '0':
return Event{AltShiftPageDown, 0, nil}
@@ -647,6 +689,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
switch r.buffer[4] {
case '1', '2', '3', '4', '5', '6', '7', '8', '9':
// Kitty iTerm2 WezTerm
// ARROW "\e[1;1D"
// SHIFT-ARROW "\e[1;2D"
// ALT-SHIFT-ARROW "\e[1;4D" "\e[1;10D" "\e[1;4D"
// CTRL-SHIFT-ARROW "\e[1;6D" N/A
@@ -701,6 +744,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if shift {
return Event{ShiftUp, 0, nil}
}
return Event{Up, 0, nil}
case 'B':
if ctrlAltShift {
return Event{CtrlAltShiftDown, 0, nil}
@@ -723,6 +767,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if shift {
return Event{ShiftDown, 0, nil}
}
return Event{Down, 0, nil}
case 'C':
if ctrlAltShift {
return Event{CtrlAltShiftRight, 0, nil}
@@ -745,6 +790,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if alt {
return Event{AltRight, 0, nil}
}
return Event{Right, 0, nil}
case 'D':
if ctrlAltShift {
return Event{CtrlAltShiftLeft, 0, nil}
@@ -767,6 +813,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if shift {
return Event{ShiftLeft, 0, nil}
}
return Event{Left, 0, nil}
case 'H':
if ctrlAltShift {
return Event{CtrlAltShiftHome, 0, nil}
@@ -789,6 +836,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if shift {
return Event{ShiftHome, 0, nil}
}
return Event{Home, 0, nil}
case 'F':
if ctrlAltShift {
return Event{CtrlAltShiftEnd, 0, nil}
@@ -811,6 +859,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if shift {
return Event{ShiftEnd, 0, nil}
}
return Event{End, 0, nil}
}
} // r.buffer[4]
} // r.buffer[3]
@@ -1030,8 +1079,8 @@ func (r *LightRenderer) MaxY() int {
}
func (r *LightRenderer) NewWindow(top int, left int, width int, height int, windowType WindowType, borderStyle BorderStyle, erase bool) Window {
width = util.Max(0, width)
height = util.Max(0, height)
width = max(0, width)
height = max(0, height)
w := &LightWindow{
renderer: r,
colored: r.theme.Colored,
@@ -1080,127 +1129,144 @@ func (w *LightWindow) DrawHBorder() {
w.drawBorder(true)
}
// drawHLine fills row `row` with `line` between optional left/right caps.
// A zero rune means "no cap"; caps are placed at the very edges of `w`.
func (w *LightWindow) drawHLine(row int, line, leftCap, rightCap rune, color ColorPair) {
w.Move(row, 0)
hw := runeWidth(line)
width := w.width
if leftCap != 0 {
w.CPrint(color, string(leftCap))
width -= runeWidth(leftCap)
}
if rightCap != 0 {
width -= runeWidth(rightCap)
}
if width < 0 {
width = 0
}
inner := width / hw
rem := width - inner*hw
w.CPrint(color, repeat(line, inner)+repeat(' ', rem))
if rightCap != 0 {
w.CPrint(color, string(rightCap))
}
}
func (w *LightWindow) DrawHSeparator(row int, windowType WindowType, useBottom bool) {
if w.height == 0 {
return
}
shape := w.border.shape
if shape == BorderNone {
return
}
color := BorderColor(windowType)
line := w.border.top
if useBottom {
line = w.border.bottom
}
var leftCap, rightCap rune
if shape.HasLeft() {
leftCap = w.border.leftMid
}
if shape.HasRight() {
rightCap = w.border.rightMid
}
w.drawHLine(row, line, leftCap, rightCap, color)
}
func (w *LightWindow) PaintSectionFrame(topContent, bottomContent int, windowType WindowType, edge SectionEdge) {
if w.height == 0 || w.border.shape == BorderNone {
return
}
color := BorderColor(windowType)
shape := w.border.shape
hasLeft := shape.HasLeft()
hasRight := shape.HasRight()
rightW := runeWidth(w.border.right)
// Content rows: overpaint left/right verticals + their 1-char margin.
for row := topContent; row <= bottomContent; row++ {
if hasLeft {
w.Move(row, 0)
w.CPrint(color, string(w.border.left)+" ")
}
if hasRight {
w.Move(row, w.width-rightW-1)
w.CPrint(color, " "+string(w.border.right))
}
}
if edge == SectionEdgeTop && shape.HasTop() {
var leftCap, rightCap rune
if hasLeft {
leftCap = w.border.topLeft
}
if hasRight {
rightCap = w.border.topRight
}
w.drawHLine(0, w.border.top, leftCap, rightCap, color)
}
if edge == SectionEdgeBottom && shape.HasBottom() {
var leftCap, rightCap rune
if hasLeft {
leftCap = w.border.bottomLeft
}
if hasRight {
rightCap = w.border.bottomRight
}
w.drawHLine(w.height-1, w.border.bottom, leftCap, rightCap, color)
}
}
func (w *LightWindow) drawBorder(onlyHorizontal bool) {
if w.height == 0 {
return
}
switch w.border.shape {
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble:
w.drawBorderAround(onlyHorizontal)
case BorderHorizontal:
w.drawBorderHorizontal(true, true)
case BorderVertical:
if onlyHorizontal {
return
}
w.drawBorderVertical(true, true)
case BorderTop:
w.drawBorderHorizontal(true, false)
case BorderBottom:
w.drawBorderHorizontal(false, true)
case BorderLeft:
if onlyHorizontal {
return
}
w.drawBorderVertical(true, false)
case BorderRight:
if onlyHorizontal {
return
}
w.drawBorderVertical(false, true)
shape := w.border.shape
if shape == BorderNone {
return
}
}
color := BorderColor(w.windowType)
hasLeft := shape.HasLeft()
hasRight := shape.HasRight()
func (w *LightWindow) drawBorderHorizontal(top, bottom bool) {
color := ColBorder
switch w.windowType {
case WindowList:
color = ColListBorder
case WindowInput:
color = ColInputBorder
case WindowHeader:
color = ColHeaderBorder
case WindowFooter:
color = ColFooterBorder
case WindowPreview:
color = ColPreviewBorder
}
hw := runeWidth(w.border.top)
if top {
w.Move(0, 0)
w.CPrint(color, repeat(w.border.top, w.width/hw))
}
if bottom {
w.Move(w.height-1, 0)
w.CPrint(color, repeat(w.border.bottom, w.width/hw))
}
}
func (w *LightWindow) drawBorderVertical(left, right bool) {
vw := runeWidth(w.border.left)
color := ColBorder
switch w.windowType {
case WindowList:
color = ColListBorder
case WindowInput:
color = ColInputBorder
case WindowHeader:
color = ColHeaderBorder
case WindowFooter:
color = ColFooterBorder
case WindowPreview:
color = ColPreviewBorder
}
for y := 0; y < w.height; y++ {
if left {
w.Move(y, 0)
w.CPrint(color, string(w.border.left))
w.CPrint(color, " ") // Margin
if shape.HasTop() {
var leftCap, rightCap rune
if hasLeft {
leftCap = w.border.topLeft
}
if right {
w.Move(y, w.width-vw-1)
w.CPrint(color, " ") // Margin
w.CPrint(color, string(w.border.right))
if hasRight {
rightCap = w.border.topRight
}
w.drawHLine(0, w.border.top, leftCap, rightCap, color)
}
}
func (w *LightWindow) drawBorderAround(onlyHorizontal bool) {
w.Move(0, 0)
color := ColBorder
switch w.windowType {
case WindowList:
color = ColListBorder
case WindowInput:
color = ColInputBorder
case WindowHeader:
color = ColHeaderBorder
case WindowFooter:
color = ColFooterBorder
case WindowPreview:
color = ColPreviewBorder
}
hw := runeWidth(w.border.top)
tcw := runeWidth(w.border.topLeft) + runeWidth(w.border.topRight)
bcw := runeWidth(w.border.bottomLeft) + runeWidth(w.border.bottomRight)
rem := (w.width - tcw) % hw
w.CPrint(color, string(w.border.topLeft)+repeat(w.border.top, (w.width-tcw)/hw)+repeat(' ', rem)+string(w.border.topRight))
if !onlyHorizontal {
if !onlyHorizontal && (hasLeft || hasRight) {
vw := runeWidth(w.border.left)
for y := 1; y < w.height-1; y++ {
w.Move(y, 0)
w.CPrint(color, string(w.border.left))
w.CPrint(color, " ") // Margin
w.Move(y, w.width-vw-1)
w.CPrint(color, " ") // Margin
w.CPrint(color, string(w.border.right))
for y := 0; y < w.height; y++ {
// Corner rows are already painted by drawHLine above / below.
if (y == 0 && shape.HasTop()) || (y == w.height-1 && shape.HasBottom()) {
continue
}
if hasLeft {
w.Move(y, 0)
w.CPrint(color, string(w.border.left)+" ")
}
if hasRight {
w.Move(y, w.width-vw-1)
w.CPrint(color, " "+string(w.border.right))
}
}
}
w.Move(w.height-1, 0)
rem = (w.width - bcw) % hw
w.CPrint(color, string(w.border.bottomLeft)+repeat(w.border.bottom, (w.width-bcw)/hw)+repeat(' ', rem)+string(w.border.bottomRight))
if shape.HasBottom() {
var leftCap, rightCap rune
if hasLeft {
leftCap = w.border.bottomLeft
}
if hasRight {
rightCap = w.border.bottomRight
}
w.drawHLine(w.height-1, w.border.bottom, leftCap, rightCap, color)
}
}
func (w *LightWindow) csi(code string) string {
@@ -1280,7 +1346,18 @@ func attrCodes(attr Attr) []string {
codes = append(codes, "3")
}
if (attr & Underline) > 0 {
codes = append(codes, "4")
switch attr.UnderlineStyle() {
case UlStyleDouble:
codes = append(codes, "4:2")
case UlStyleCurly:
codes = append(codes, "4:3")
case UlStyleDotted:
codes = append(codes, "4:4")
case UlStyleDashed:
codes = append(codes, "4:5")
default:
codes = append(codes, "4")
}
}
if (attr & Blink) > 0 {
codes = append(codes, "5")
@@ -1318,8 +1395,27 @@ func colorCodes(fg Color, bg Color) []string {
return codes
}
func (w *LightWindow) csiColor(fg Color, bg Color, attr Attr) (bool, string) {
func ulColorCode(c Color) string {
if c == colDefault {
return ""
}
if c.is24() {
r := (c >> 16) & 0xff
g := (c >> 8) & 0xff
b := (c) & 0xff
return fmt.Sprintf("58;2;%d;%d;%d", r, g, b)
}
if c >= 0 && c < 256 {
return fmt.Sprintf("58;5;%d", c)
}
return ""
}
func (w *LightWindow) csiColor(fg Color, bg Color, ul Color, attr Attr) (bool, string) {
codes := append(attrCodes(attr), colorCodes(fg, bg)...)
if ulCode := ulColorCode(ul); ulCode != "" {
codes = append(codes, ulCode)
}
code := w.csi(";" + strings.Join(codes, ";") + "m")
return len(codes) > 0, code
}
@@ -1333,65 +1429,28 @@ func cleanse(str string) string {
}
func (w *LightWindow) CPrint(pair ColorPair, text string) {
_, code := w.csiColor(pair.Fg(), pair.Bg(), pair.Attr())
_, code := w.csiColor(pair.Fg(), pair.Bg(), pair.Ul(), pair.Attr())
w.stderrInternal(cleanse(text), false, code)
w.csi("0m")
}
func (w *LightWindow) cprint2(fg Color, bg Color, attr Attr, text string) {
hasColors, code := w.csiColor(fg, bg, attr)
hasColors, code := w.csiColor(fg, bg, colDefault, attr)
if hasColors {
defer w.csi("0m")
}
w.stderrInternal(cleanse(text), false, code)
}
type wrappedLine struct {
text string
displayWidth int
}
func wrapLine(input string, prefixLength int, initialMax int, tabstop int, wrapSignWidth int) []wrappedLine {
lines := []wrappedLine{}
width := 0
line := ""
gr := uniseg.NewGraphemes(input)
max := initialMax
for gr.Next() {
rs := gr.Runes()
str := string(rs)
var w int
if len(rs) == 1 && rs[0] == '\t' {
w = tabstop - (prefixLength+width)%tabstop
str = repeat(' ', w)
} else if rs[0] == '\r' {
w++
} else {
w = uniseg.StringWidth(str)
}
width += w
if prefixLength+width <= max {
line += str
} else {
lines = append(lines, wrappedLine{string(line), width - w})
line = str
prefixLength = 0
width = w
max = initialMax - wrapSignWidth
}
}
lines = append(lines, wrappedLine{string(line), width})
return lines
}
func (w *LightWindow) fill(str string, resetCode string) FillReturn {
allLines := strings.Split(str, "\n")
for i, line := range allLines {
lines := wrapLine(line, w.posx, w.width, w.tabstop, w.wrapSignWidth)
lines := WrapLine(line, w.posx, w.width, w.tabstop, w.wrapSignWidth)
for j, wl := range lines {
w.stderrInternal(wl.text, false, resetCode)
w.posx += wl.displayWidth
if w.posx < w.width {
w.stderrInternal(wl.Text, false, resetCode)
w.posx += wl.DisplayWidth
}
// Wrap line
if j < len(lines)-1 || i < len(allLines)-1 {
@@ -1429,7 +1488,7 @@ func (w *LightWindow) fill(str string, resetCode string) FillReturn {
func (w *LightWindow) setBg() string {
if w.bg != colDefault {
_, code := w.csiColor(colDefault, w.bg, AttrRegular)
_, code := w.csiColor(colDefault, w.bg, colDefault, AttrRegular)
return code
}
// Should clear dim attribute after ␍ in the preview window
@@ -1451,7 +1510,7 @@ func (w *LightWindow) Fill(text string) FillReturn {
return w.fill(text, code)
}
func (w *LightWindow) CFill(fg Color, bg Color, attr Attr, text string) FillReturn {
func (w *LightWindow) CFill(fg Color, bg Color, ul Color, attr Attr, text string) FillReturn {
w.Move(w.posy, w.posx)
if fg == colDefault {
fg = w.fg
@@ -1459,7 +1518,7 @@ func (w *LightWindow) CFill(fg Color, bg Color, attr Attr, text string) FillRetu
if bg == colDefault {
bg = w.bg
}
if hasColors, resetCode := w.csiColor(fg, bg, attr); hasColors {
if hasColors, resetCode := w.csiColor(fg, bg, ul, attr); hasColors {
defer w.csi("0m")
return w.fill(text, resetCode)
}
+18 -1
View File
@@ -15,10 +15,27 @@ func TestLightRenderer(t *testing.T) {
light_renderer := renderer.(*LightRenderer)
go func() {
for {
light_renderer.mutex.Lock()
ready := light_renderer.cancel != nil
light_renderer.mutex.Unlock()
if ready {
light_renderer.CancelGetChar()
break
}
}
}()
event := light_renderer.GetChar(true)
if event.Type != Invalid {
t.Error("Not cancelled")
}
assertCharSequence := func(sequence string, name string) {
bytes := []byte(sequence)
light_renderer.buffer = bytes
event := light_renderer.GetChar()
event := light_renderer.GetChar(true)
if event.KeyName() != name {
t.Errorf(
"sequence: %q | %v | '%s' (%s) != %s",
+56 -9
View File
@@ -98,8 +98,8 @@ func (r *LightRenderer) findOffset() (row int, col int) {
r.flush()
var err error
bytes := []byte{}
for tries := 0; tries < offsetPollTries; tries++ {
bytes, err = r.getBytesInternal(bytes, tries > 0)
for tries := range offsetPollTries {
bytes, _, err = r.getBytesInternal(false, bytes, tries > 0)
if err != nil {
return -1, -1
}
@@ -114,15 +114,62 @@ func (r *LightRenderer) findOffset() (row int, col int) {
return -1, -1
}
func (r *LightRenderer) getch(nonblock bool) (int, bool) {
b := make([]byte, 1)
func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) {
fd := r.fd()
util.SetNonblock(r.ttyin, nonblock)
_, err := util.Read(fd, b)
if err != nil {
return 0, false
getter := func() (int, getCharResult) {
b := make([]byte, 1)
util.SetNonblock(r.ttyin, nonblock)
_, err := util.Read(fd, b)
if err != nil {
return 0, getCharError
}
return int(b[0]), getCharSuccess
}
return int(b[0]), true
if nonblock || !cancellable {
return getter()
}
rpipe, wpipe, err := os.Pipe()
if err != nil {
// Fallback to blocking read without cancellation
return getter()
}
r.setCancel(func() {
wpipe.Write([]byte{0})
})
defer func() {
r.setCancel(nil)
rpipe.Close()
wpipe.Close()
}()
cancelFd := int(rpipe.Fd())
for range maxSelectTries {
var rfds unix.FdSet
limit := len(rfds.Bits) * unix.NFDBITS
if fd >= limit || cancelFd >= limit {
return getter()
}
rfds.Set(fd)
rfds.Set(cancelFd)
_, err := unix.Select(max(fd, cancelFd)+1, &rfds, nil, nil, nil)
if err != nil {
if err == syscall.EINTR {
continue
}
return 0, getCharError
}
if rfds.IsSet(cancelFd) {
return 0, getCharCancelled
}
if rfds.IsSet(fd) {
return getter()
}
}
return 0, getCharError
}
func (r *LightRenderer) Size() TermSize {
+27 -10
View File
@@ -151,16 +151,33 @@ func (r *LightRenderer) findOffset() (row int, col int) {
return int(bufferInfo.CursorPosition.Y), int(bufferInfo.CursorPosition.X)
}
func (r *LightRenderer) getch(nonblock bool) (int, bool) {
if nonblock {
select {
case bc := <-r.ttyinChannel:
return int(bc), true
case <-time.After(timeoutInterval * time.Millisecond):
return 0, false
}
} else {
func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) {
if !nonblock && !cancellable {
bc := <-r.ttyinChannel
return int(bc), true
return int(bc), getCharSuccess
}
var timeout <-chan time.Time
if nonblock {
timeout = time.After(timeoutInterval * time.Millisecond)
}
var cancel chan struct{}
if cancellable {
cancel = make(chan struct{})
r.setCancel(func() {
close(cancel)
})
defer r.setCancel(nil)
}
select {
case bc := <-r.ttyinChannel:
return int(bc), getCharSuccess
case <-cancel:
return 0, getCharCancelled
case <-timeout:
// NOTE: not really an error
return 0, getCharError
}
}
+227 -129
View File
@@ -5,6 +5,7 @@ package tui
import (
"os"
"regexp"
"strings"
"time"
"github.com/gdamore/tcell/v2"
@@ -36,8 +37,6 @@ func (p ColorPair) style() tcell.Style {
return style.Foreground(asTcellColor(p.Fg())).Background(asTcellColor(p.Bg()))
}
type Attr int32
type TcellWindow struct {
color bool
windowType WindowType
@@ -55,6 +54,7 @@ type TcellWindow struct {
showCursor bool
wrapSign string
wrapSignWidth int
tabstop int
}
func (w *TcellWindow) Top() int {
@@ -98,14 +98,6 @@ const (
Italic = Attr(tcell.AttrItalic)
)
const (
AttrUndefined = Attr(0)
AttrRegular = Attr(1 << 7)
AttrClear = Attr(1 << 8)
BoldForce = Attr(1 << 10)
FullBg = Attr(1 << 11)
)
func (r *FullscreenRenderer) Bell() {
_screen.Beep()
}
@@ -159,15 +151,6 @@ func (c Color) Style() tcell.Color {
}
}
func (a Attr) Merge(b Attr) Attr {
if b&AttrRegular > 0 {
// Only keep bold attribute set by the system
return (b &^ AttrRegular) | (a & BoldForce)
}
return (a &^ AttrRegular) | b
}
// handle the following as private members of FullscreenRenderer instance
// they are declared here to prevent introducing tcell library in non-windows builds
var (
@@ -265,7 +248,7 @@ func (r *FullscreenRenderer) Size() TermSize {
return TermSize{lines, cols, 0, 0}
}
func (r *FullscreenRenderer) GetChar() Event {
func (r *FullscreenRenderer) GetChar(cancellable bool) Event {
ev := _screen.PollEvent()
switch ev := ev.(type) {
case *tcell.EventPaste:
@@ -722,6 +705,10 @@ func (r *FullscreenRenderer) GetChar() Event {
return Event{Invalid, 0, nil}
}
func (r *FullscreenRenderer) CancelGetChar() {
// TODO
}
func (r *FullscreenRenderer) Pause(clear bool) {
if clear {
_screen.Suspend()
@@ -748,8 +735,8 @@ func (r *FullscreenRenderer) RefreshWindows(windows []Window) {
}
func (r *FullscreenRenderer) NewWindow(top int, left int, width int, height int, windowType WindowType, borderStyle BorderStyle, erase bool) Window {
width = util.Max(0, width)
height = util.Max(0, height)
width = max(0, width)
height = max(0, height)
normal := ColBorder
switch windowType {
case WindowList:
@@ -772,7 +759,8 @@ func (r *FullscreenRenderer) NewWindow(top int, left int, width int, height int,
height: height,
normal: normal,
borderStyle: borderStyle,
showCursor: r.showCursor}
showCursor: r.showCursor,
tabstop: r.tabstop}
w.Erase()
return w
}
@@ -840,6 +828,21 @@ func (w *TcellWindow) withUrl(style tcell.Style) tcell.Style {
return style
}
func underlineStyleFromAttr(a Attr) tcell.UnderlineStyle {
switch a.UnderlineStyle() {
case UlStyleDouble:
return tcell.UnderlineStyleDouble
case UlStyleCurly:
return tcell.UnderlineStyleCurly
case UlStyleDotted:
return tcell.UnderlineStyleDotted
case UlStyleDashed:
return tcell.UnderlineStyleDashed
default:
return tcell.UnderlineStyleSolid
}
}
func (w *TcellWindow) printString(text string, pair ColorPair) {
lx := 0
a := pair.Attr()
@@ -848,11 +851,18 @@ func (w *TcellWindow) printString(text string, pair ColorPair) {
if a&AttrClear == 0 {
style = style.
Reverse(a&Attr(tcell.AttrReverse) != 0).
Underline(a&Attr(tcell.AttrUnderline) != 0).
StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0).
Italic(a&Attr(tcell.AttrItalic) != 0).
Blink(a&Attr(tcell.AttrBlink) != 0).
Dim(a&Attr(tcell.AttrDim) != 0)
if a&Attr(tcell.AttrUnderline) != 0 {
style = style.Underline(underlineStyleFromAttr(a))
if pair.Ul() != colDefault {
style = style.Underline(asTcellColor(pair.Ul()))
}
} else {
style = style.Underline(false)
}
}
style = w.withUrl(style)
@@ -887,10 +897,8 @@ func (w *TcellWindow) CPrint(pair ColorPair, text string) {
w.printString(text, pair)
}
func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn {
lx := 0
func (w *TcellWindow) pairStyle(pair ColorPair) tcell.Style {
a := pair.Attr()
var style tcell.Style
if w.color {
style = pair.style()
@@ -902,64 +910,73 @@ func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn {
Bold(a&Attr(tcell.AttrBold) != 0 || a&BoldForce != 0).
Dim(a&Attr(tcell.AttrDim) != 0).
Reverse(a&Attr(tcell.AttrReverse) != 0).
Underline(a&Attr(tcell.AttrUnderline) != 0).
StrikeThrough(a&Attr(tcell.AttrStrikeThrough) != 0).
Italic(a&Attr(tcell.AttrItalic) != 0)
style = w.withUrl(style)
if a&Attr(tcell.AttrUnderline) != 0 {
style = style.Underline(underlineStyleFromAttr(a))
if pair.Ul() != colDefault {
style = style.Underline(asTcellColor(pair.Ul()))
}
} else {
style = style.Underline(false)
}
return w.withUrl(style)
}
func (w *TcellWindow) renderGraphemes(text string, style tcell.Style) {
gr := uniseg.NewGraphemes(text)
Loop:
for gr.Next() {
st := style
rs := gr.Runes()
if len(rs) == 1 {
r := rs[0]
switch r {
case '\r':
st = style.Dim(true)
rs[0] = '␍'
case '\n':
w.lastY++
w.lastX = 0
lx = 0
continue Loop
}
}
// word wrap:
xPos := w.left + w.lastX + lx
if xPos >= w.left+w.width {
w.lastY++
if w.lastY >= w.height {
return FillSuspend
}
w.lastX = 0
lx = 0
xPos = w.left
sign := w.wrapSign
if w.wrapSignWidth > w.width {
runes, _ := util.Truncate(sign, w.width)
sign = string(runes)
}
wgr := uniseg.NewGraphemes(sign)
for wgr.Next() {
rs := wgr.Runes()
_screen.SetContent(w.left+lx, w.top+w.lastY, rs[0], rs[1:], style.Dim(true))
lx += uniseg.StringWidth(string(rs))
}
xPos = w.left + lx
if len(rs) == 1 && rs[0] == '\r' {
st = style.Dim(true)
rs[0] = '␍'
}
xPos := w.left + w.lastX
yPos := w.top + w.lastY
if yPos >= (w.top + w.height) {
return FillSuspend
if xPos < (w.left+w.width) && yPos < (w.top+w.height) {
_screen.SetContent(xPos, yPos, rs[0], rs[1:], st)
}
_screen.SetContent(xPos, yPos, rs[0], rs[1:], st)
lx += util.StringWidth(string(rs))
w.lastX += util.StringWidth(string(rs))
}
w.lastX += lx
if w.lastX == w.width {
}
func (w *TcellWindow) renderWrapSign(style tcell.Style) {
sign := w.wrapSign
if w.wrapSignWidth > w.width {
runes, _ := util.Truncate(sign, w.width)
sign = string(runes)
}
gr := uniseg.NewGraphemes(sign)
for gr.Next() {
rs := gr.Runes()
_screen.SetContent(w.left+w.lastX, w.top+w.lastY, rs[0], rs[1:], style.Dim(true))
w.lastX += uniseg.StringWidth(string(rs))
}
}
func (w *TcellWindow) fillString(text string, pair ColorPair) FillReturn {
style := w.pairStyle(pair)
for i, segment := range strings.Split(text, "\n") {
for j, wl := range WrapLine(segment, w.lastX, w.width, w.tabstop, w.wrapSignWidth) {
if i > 0 || j > 0 {
w.lastY++
if w.lastY >= w.height {
return FillSuspend
}
w.lastX = 0
if j > 0 {
w.renderWrapSign(style)
}
}
if w.lastX < w.width {
w.renderGraphemes(wl.Text, style)
}
}
}
if w.lastX >= w.width {
w.lastY++
w.lastX = 0
return FillNextLine
@@ -982,14 +999,14 @@ func (w *TcellWindow) LinkEnd() {
w.params = nil
}
func (w *TcellWindow) CFill(fg Color, bg Color, a Attr, str string) FillReturn {
func (w *TcellWindow) CFill(fg Color, bg Color, ul Color, a Attr, str string) FillReturn {
if fg == colDefault {
fg = w.normal.Fg()
}
if bg == colDefault {
bg = w.normal.Bg()
}
return w.fillString(str, NewColorPair(fg, bg, a))
return w.fillString(str, NewColorPair(fg, bg, a).WithUl(ul))
}
func (w *TcellWindow) DrawBorder() {
@@ -1000,6 +1017,115 @@ func (w *TcellWindow) DrawHBorder() {
w.drawBorder(true)
}
// borderStyleFor returns the tcell.Style used to draw borders for `wt`, honoring
// whether the window is rendering with colors.
func (w *TcellWindow) borderStyleFor(wt WindowType) tcell.Style {
if !w.color {
return w.normal.style()
}
return BorderColor(wt).style()
}
// drawHLine fills row `y` with `line` between optional left/right caps.
// A zero rune means "no cap"; caps are placed at the very edges of `w`.
// tcell has an issue displaying two overlapping wide runes, so the line
// stops before the cap position rather than overpainting.
func (w *TcellWindow) drawHLine(y int, line, leftCap, rightCap rune, style tcell.Style) {
left := w.left
right := left + w.width
hw := runeWidth(line)
lw := 0
rw := 0
if leftCap != 0 {
lw = runeWidth(leftCap)
}
if rightCap != 0 {
rw = runeWidth(rightCap)
}
for x := left + lw; x <= right-rw-hw; x += hw {
_screen.SetContent(x, y, line, nil, style)
}
if leftCap != 0 {
_screen.SetContent(left, y, leftCap, nil, style)
}
if rightCap != 0 {
_screen.SetContent(right-rw, y, rightCap, nil, style)
}
}
func (w *TcellWindow) DrawHSeparator(row int, windowType WindowType, useBottom bool) {
if w.height == 0 {
return
}
shape := w.borderStyle.shape
if shape == BorderNone {
return
}
style := w.borderStyleFor(windowType)
line := w.borderStyle.top
if useBottom {
line = w.borderStyle.bottom
}
var leftCap, rightCap rune
if shape.HasLeft() {
leftCap = w.borderStyle.leftMid
}
if shape.HasRight() {
rightCap = w.borderStyle.rightMid
}
w.drawHLine(w.top+row, line, leftCap, rightCap, style)
}
func (w *TcellWindow) PaintSectionFrame(topContent, bottomContent int, windowType WindowType, edge SectionEdge) {
if w.height == 0 {
return
}
shape := w.borderStyle.shape
if shape == BorderNone {
return
}
style := w.borderStyleFor(windowType)
left := w.left
right := left + w.width
hasLeft := shape.HasLeft()
hasRight := shape.HasRight()
leftW := runeWidth(w.borderStyle.left)
rightW := runeWidth(w.borderStyle.right)
// Content rows: overpaint the left and right verticals (+ their 1-char margin) in
// the section's color. Inner margin stays at whatever bg the sub-window set.
for row := topContent; row <= bottomContent; row++ {
y := w.top + row
if hasLeft {
_screen.SetContent(left, y, w.borderStyle.left, nil, style)
_screen.SetContent(left+leftW, y, ' ', nil, style)
}
if hasRight {
_screen.SetContent(right-rightW-1, y, ' ', nil, style)
_screen.SetContent(right-rightW, y, w.borderStyle.right, nil, style)
}
}
if edge == SectionEdgeTop && shape.HasTop() {
var leftCap, rightCap rune
if hasLeft {
leftCap = w.borderStyle.topLeft
}
if hasRight {
rightCap = w.borderStyle.topRight
}
w.drawHLine(w.top, w.borderStyle.top, leftCap, rightCap, style)
}
if edge == SectionEdgeBottom && shape.HasBottom() {
var leftCap, rightCap rune
if hasLeft {
leftCap = w.borderStyle.bottomLeft
}
if hasRight {
rightCap = w.borderStyle.bottomRight
}
w.drawHLine(w.top+w.height-1, w.borderStyle.bottom, leftCap, rightCap, style)
}
}
func (w *TcellWindow) drawBorder(onlyHorizontal bool) {
if w.height == 0 {
return
@@ -1014,72 +1140,44 @@ func (w *TcellWindow) drawBorder(onlyHorizontal bool) {
top := w.top
bot := top + w.height
var style tcell.Style
if w.color {
switch w.windowType {
case WindowBase:
style = ColBorder.style()
case WindowList:
style = ColListBorder.style()
case WindowHeader:
style = ColHeaderBorder.style()
case WindowFooter:
style = ColFooterBorder.style()
case WindowInput:
style = ColInputBorder.style()
case WindowPreview:
style = ColPreviewBorder.style()
}
} else {
style = w.normal.style()
}
style := w.borderStyleFor(w.windowType)
hw := runeWidth(w.borderStyle.top)
switch shape {
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderHorizontal, BorderTop:
max := right - 2*hw
if shape == BorderHorizontal || shape == BorderTop {
max = right - hw
hasLeft := shape.HasLeft()
hasRight := shape.HasRight()
if shape.HasTop() {
var leftCap, rightCap rune
if hasLeft {
leftCap = w.borderStyle.topLeft
}
// tcell has an issue displaying two overlapping wide runes
// e.g. SetContent( HH )
// SetContent( TR )
// ==================
// ( HH ) => TR is ignored
for x := left; x <= max; x += hw {
_screen.SetContent(x, top, w.borderStyle.top, nil, style)
if hasRight {
rightCap = w.borderStyle.topRight
}
w.drawHLine(top, w.borderStyle.top, leftCap, rightCap, style)
}
switch shape {
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderHorizontal, BorderBottom:
max := right - 2*hw
if shape == BorderHorizontal || shape == BorderBottom {
max = right - hw
if shape.HasBottom() {
var leftCap, rightCap rune
if hasLeft {
leftCap = w.borderStyle.bottomLeft
}
for x := left; x <= max; x += hw {
_screen.SetContent(x, bot-1, w.borderStyle.bottom, nil, style)
if hasRight {
rightCap = w.borderStyle.bottomRight
}
w.drawHLine(bot-1, w.borderStyle.bottom, leftCap, rightCap, style)
}
if !onlyHorizontal {
switch shape {
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderVertical, BorderLeft:
for y := top; y < bot; y++ {
vw := runeWidth(w.borderStyle.right)
for y := top; y < bot; y++ {
// Corner rows are already painted by drawHLine above / below.
if (y == top && shape.HasTop()) || (y == bot-1 && shape.HasBottom()) {
continue
}
if hasLeft {
_screen.SetContent(left, y, w.borderStyle.left, nil, style)
}
}
switch shape {
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble, BorderVertical, BorderRight:
vw := runeWidth(w.borderStyle.right)
for y := top; y < bot; y++ {
if hasRight {
_screen.SetContent(right-vw, y, w.borderStyle.right, nil, style)
}
}
}
switch shape {
case BorderRounded, BorderSharp, BorderBold, BorderBlock, BorderThinBlock, BorderDouble:
_screen.SetContent(left, top, w.borderStyle.topLeft, nil, style)
_screen.SetContent(right-runeWidth(w.borderStyle.topRight), top, w.borderStyle.topRight, nil, style)
_screen.SetContent(left, bot-1, w.borderStyle.bottomLeft, nil, style)
_screen.SetContent(right-runeWidth(w.borderStyle.bottomRight), bot-1, w.borderStyle.bottomRight, nil, style)
}
}
+6 -6
View File
@@ -253,7 +253,7 @@ func TestGetCharEventKey(t *testing.T) {
{giveKey{tcell.KeyPause, 0, tcell.ModNone}, wantKey{Invalid, 0, nil}}, // unhandled
}
r := NewFullscreenRenderer(&ColorTheme{}, false, false)
r := NewFullscreenRenderer(&ColorTheme{}, false, false, 8)
r.Init()
// run and evaluate the tests
@@ -265,22 +265,22 @@ func TestGetCharEventKey(t *testing.T) {
t.Logf("giveEvent = %T{key: %v, ch: %q (%[3]v), mod: %#04b}\n", giveEvent, giveEvent.Key(), giveEvent.Rune(), giveEvent.Modifiers())
// process the event in fzf and evaluate the test
gotEvent := r.GetChar()
gotEvent := r.GetChar(true)
// skip Resize events, those are sometimes put in the buffer outside of this test
if initialResizeAsInvalid && gotEvent.Type == Invalid {
t.Logf("Resize as Invalid swallowed")
initialResizeAsInvalid = false
gotEvent = r.GetChar()
gotEvent = r.GetChar(true)
}
if gotEvent.Type == Resize {
t.Logf("Resize swallowed")
gotEvent = r.GetChar()
gotEvent = r.GetChar(true)
}
t.Logf("wantEvent = %T{Type: %v, Char: %q (%[3]v)}\n", test.wantKey, test.wantKey.Type, test.wantKey.Char)
t.Logf("gotEvent = %T{Type: %v, Char: %q (%[3]v)}\n", gotEvent, gotEvent.Type, gotEvent.Char)
assert(t, "r.GetChar().Type", gotEvent.Type, test.wantKey.Type)
assert(t, "r.GetChar().Char", gotEvent.Char, test.wantKey.Char)
assert(t, "r.GetChar(true).Type", gotEvent.Type, test.wantKey.Type)
assert(t, "r.GetChar(true).Char", gotEvent.Char, test.wantKey.Char)
}
r.Close()
+502 -228
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -2,6 +2,46 @@ package tui
import "testing"
func TestWrapLine(t *testing.T) {
// Basic wrapping
lines := WrapLine("hello world", 0, 7, 8, 2)
if len(lines) != 2 || lines[0].Text != "hello w" || lines[1].Text != "orld" {
t.Errorf("Basic wrap: %v", lines)
}
// Exact fit - no wrapping needed
lines = WrapLine("hello", 0, 5, 8, 2)
if len(lines) != 1 || lines[0].Text != "hello" || lines[0].DisplayWidth != 5 {
t.Errorf("Exact fit: %v", lines)
}
// With prefix length
lines = WrapLine("hello", 3, 5, 8, 2)
if len(lines) != 2 || lines[0].Text != "he" || lines[1].Text != "llo" {
t.Errorf("Prefix length: %v", lines)
}
// Empty string
lines = WrapLine("", 0, 10, 8, 2)
if len(lines) != 1 || lines[0].Text != "" || lines[0].DisplayWidth != 0 {
t.Errorf("Empty string: %v", lines)
}
// Continuation lines account for wrapSignWidth
lines = WrapLine("abcdefghij", 0, 5, 8, 2)
// First line: "abcde" (5 chars fit in width 5)
// Continuation max: 5-2=3, so "fgh" then "ij"
if len(lines) != 3 || lines[0].Text != "abcde" || lines[1].Text != "fgh" || lines[2].Text != "ij" {
t.Errorf("Continuation: %v", lines)
}
// Tab expansion
lines = WrapLine("\there", 0, 10, 4, 2)
if len(lines) != 1 || lines[0].DisplayWidth != 8 {
t.Errorf("Tab: %v", lines)
}
}
func TestHexToColor(t *testing.T) {
assert := func(expr string, r, g, b int) {
color := HexToColor(expr)
+1 -1
View File
@@ -8,7 +8,7 @@ import (
func TestAtExit(t *testing.T) {
want := []int{3, 2, 1, 0}
var called []int
for i := 0; i < 4; i++ {
for i := range 4 {
n := i
AtExit(func() { called = append(called, n) })
}
+17 -4
View File
@@ -52,7 +52,7 @@ func ToChars(bytes []byte) Chars {
}
runes := make([]rune, bytesUntil, len(bytes))
for i := 0; i < bytesUntil; i++ {
for i := range bytesUntil {
runes[i] = rune(bytes[i])
}
for i := bytesUntil; i < len(bytes); {
@@ -187,7 +187,7 @@ func (chars *Chars) TrailingWhitespaces() int {
func (chars *Chars) TrimTrailingWhitespaces(maxIndex int) {
whitespaces := chars.TrailingWhitespaces()
end := len(chars.slice) - whitespaces
chars.slice = chars.slice[0:Max(end, maxIndex)]
chars.slice = chars.slice[0:max(end, maxIndex)]
}
func (chars *Chars) TrimSuffix(runes []rune) {
@@ -249,7 +249,7 @@ func (chars *Chars) Prepend(prefix string) {
}
}
func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int) ([][]rune, bool) {
func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int, wrapWord bool) ([][]rune, bool) {
text := make([]rune, chars.Length())
copy(text, chars.ToRunes())
@@ -259,7 +259,7 @@ func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWi
lines = append(lines, text)
} else {
from := 0
for off := 0; off < len(text); off++ {
for off := range text {
if text[off] == '\n' {
lines = append(lines, text[from:off+1]) // Include '\n'
from = off + 1
@@ -307,6 +307,19 @@ func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWi
if overflowIdx == 0 {
overflowIdx = 1
}
if wrapWord {
// Find last space/tab at or before overflowIdx
breakIdx := -1
for k := overflowIdx; k > 0; k-- {
if line[k-1] == ' ' || line[k-1] == '\t' {
breakIdx = k
break
}
}
if breakIdx > 0 {
overflowIdx = breakIdx
}
}
if len(wrapped) >= maxLines {
return wrapped, true
}
+49 -1
View File
@@ -51,7 +51,7 @@ func TestTrimLength(t *testing.T) {
func TestCharsLines(t *testing.T) {
chars := ToChars([]byte("abcdef\n가나다\n\tdef"))
check := func(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int, expectedNumLines int, expectedOverflow bool) {
lines, overflow := chars.Lines(multiLine, maxLines, wrapCols, wrapSignWidth, tabstop)
lines, overflow := chars.Lines(multiLine, maxLines, wrapCols, wrapSignWidth, tabstop, false)
fmt.Println(lines, overflow)
if len(lines) != expectedNumLines || overflow != expectedOverflow {
t.Errorf("Invalid result: %d %v (expected %d %v)", len(lines), overflow, expectedNumLines, expectedOverflow)
@@ -81,3 +81,51 @@ func TestCharsLines(t *testing.T) {
// With wrap sign (3 + 2) and no multi-line
check(false, 100, 3, 2, 1, 13, false)
}
func TestCharsLinesWrapWord(t *testing.T) {
// "hello world foo bar" with width 12 should break at word boundaries
chars := ToChars([]byte("hello world foo bar"))
lines, overflow := chars.Lines(false, 100, 12, 0, 8, true)
// "hello world " (12) | "foo bar" (7)
if len(lines) != 2 || overflow {
t.Errorf("Expected 2 lines, got %d (overflow: %v): %v", len(lines), overflow, lines)
}
if string(lines[0]) != "hello world " {
t.Errorf("Expected first line 'hello world ', got %q", string(lines[0]))
}
if string(lines[1]) != "foo bar" {
t.Errorf("Expected second line 'foo bar', got %q", string(lines[1]))
}
// No word boundary: a single long word falls back to character wrap
chars2 := ToChars([]byte("abcdefghijklmnop"))
lines2, _ := chars2.Lines(false, 100, 10, 0, 8, true)
if len(lines2) != 2 {
t.Errorf("Expected 2 lines for long word, got %d: %v", len(lines2), lines2)
}
if string(lines2[0]) != "abcdefghij" {
t.Errorf("Expected first line 'abcdefghij', got %q", string(lines2[0]))
}
// Tab as word boundary
chars3 := ToChars([]byte("hello\tworld"))
lines3, _ := chars3.Lines(false, 100, 7, 0, 8, true)
// "hello\t" should break at tab (width of tab at pos 5 with tabstop 8 = 3, total width = 8 > 7)
// Actually RunesWidth: 'h'=1,'e'=1,'l'=1,'l'=1,'o'=1,'\t'=3 = 8 > 7, overflowIdx=5
// Then word-wrap scans back and finds no space/tab before idx 5 (tab IS at idx 5 but we check line[k-1])
// Wait - let me think: overflowIdx=5, we check k=5 -> line[4]='o', k=4 -> line[3]='l'... no space/tab found
// Falls back to character wrap: "hello" | "\tworld"
if len(lines3) < 2 {
t.Errorf("Expected at least 2 lines for tab test, got %d: %v", len(lines3), lines3)
}
// wrapWord=false still character-wraps
chars4 := ToChars([]byte("hello world"))
lines4, _ := chars4.Lines(false, 100, 8, 0, 8, false)
if len(lines4) != 2 {
t.Errorf("Expected 2 lines with wrapWord=false, got %d: %v", len(lines4), lines4)
}
if string(lines4[0]) != "hello wo" {
t.Errorf("Expected first line 'hello wo', got %q", string(lines4[0]))
}
}
+10 -63
View File
@@ -1,11 +1,11 @@
package util
import (
"cmp"
"math"
"os"
"strconv"
"strings"
"time"
"github.com/mattn/go-isatty"
"github.com/rivo/uniseg"
@@ -18,8 +18,13 @@ func StringWidth(s string) int {
// RunesWidth returns runes width
func RunesWidth(runes []rune, prefixWidth int, tabstop int, limit int) (int, int) {
return StringsWidth(string(runes), prefixWidth, tabstop, limit)
}
// StringsWidth returns the width of the string
func StringsWidth(str string, prefixWidth int, tabstop int, limit int) (int, int) {
width := 0
gr := uniseg.NewGraphemes(string(runes))
gr := uniseg.NewGraphemes(str)
idx := 0
for gr.Next() {
rs := gr.Runes()
@@ -55,54 +60,8 @@ func Truncate(input string, limit int) ([]rune, int) {
return runes, width
}
// Max returns the largest integer
func Max(first int, second int) int {
if first >= second {
return first
}
return second
}
// Max16 returns the largest integer
func Max16(first int16, second int16) int16 {
if first >= second {
return first
}
return second
}
// Max32 returns the largest 32-bit integer
func Max32(first int32, second int32) int32 {
if first > second {
return first
}
return second
}
// Min returns the smallest integer
func Min(first int, second int) int {
if first <= second {
return first
}
return second
}
// Min32 returns the smallest 32-bit integer
func Min32(first int32, second int32) int32 {
if first <= second {
return first
}
return second
}
// Constrain32 limits the given 32-bit integer with the upper and lower bounds
func Constrain32(val int32, min int32, max int32) int32 {
return Max32(Min32(val, max), min)
}
// Constrain limits the given integer with the upper and lower bounds
func Constrain(val int, min int, max int) int {
return Max(Min(val, max), min)
func Constrain[T cmp.Ordered](val, minimum, maximum T) T {
return max(min(val, maximum), minimum)
}
func AsUint16(val int) uint16 {
@@ -114,18 +73,6 @@ func AsUint16(val int) uint16 {
return uint16(val)
}
// DurWithin limits the given time.Duration with the upper and lower bounds
func DurWithin(
val time.Duration, min time.Duration, max time.Duration) time.Duration {
if val < min {
return min
}
if val > max {
return max
}
return val
}
// IsTty returns true if the file is a terminal
func IsTty(file *os.File) bool {
fd := file.Fd()
@@ -197,7 +144,7 @@ func CompareVersions(v1, v2 string) int {
return n
}
for i := 0; i < Max(len(parts1), len(parts2)); i++ {
for i := 0; i < max(len(parts1), len(parts2)); i++ {
var p1, p2 int
if i < len(parts1) {
p1 = atoi(parts1[i])
-92
View File
@@ -4,72 +4,8 @@ import (
"math"
"strings"
"testing"
"time"
)
func TestMax(t *testing.T) {
if Max(10, 1) != 10 {
t.Error("Expected", 10)
}
if Max(-2, 5) != 5 {
t.Error("Expected", 5)
}
}
func TestMax16(t *testing.T) {
if Max16(10, 1) != 10 {
t.Error("Expected", 10)
}
if Max16(-2, 5) != 5 {
t.Error("Expected", 5)
}
if Max16(math.MaxInt16, 0) != math.MaxInt16 {
t.Error("Expected", math.MaxInt16)
}
if Max16(0, math.MinInt16) != 0 {
t.Error("Expected", 0)
}
}
func TestMax32(t *testing.T) {
if Max32(10, 1) != 10 {
t.Error("Expected", 10)
}
if Max32(-2, 5) != 5 {
t.Error("Expected", 5)
}
if Max32(math.MaxInt32, 0) != math.MaxInt32 {
t.Error("Expected", math.MaxInt32)
}
if Max32(0, math.MinInt32) != 0 {
t.Error("Expected", 0)
}
}
func TestMin(t *testing.T) {
if Min(10, 1) != 1 {
t.Error("Expected", 1)
}
if Min(-2, 5) != -2 {
t.Error("Expected", -2)
}
}
func TestMin32(t *testing.T) {
if Min32(10, 1) != 1 {
t.Error("Expected", 1)
}
if Min32(-2, 5) != -2 {
t.Error("Expected", -2)
}
if Min32(math.MaxInt32, 0) != 0 {
t.Error("Expected", 0)
}
if Min32(0, math.MinInt32) != math.MinInt32 {
t.Error("Expected", math.MinInt32)
}
}
func TestConstrain(t *testing.T) {
if Constrain(-3, -1, 3) != -1 {
t.Error("Expected", -1)
@@ -83,22 +19,6 @@ func TestConstrain(t *testing.T) {
}
}
func TestConstrain32(t *testing.T) {
if Constrain32(-3, -1, 3) != -1 {
t.Error("Expected", -1)
}
if Constrain32(2, -1, 3) != 2 {
t.Error("Expected", 2)
}
if Constrain32(5, -1, 3) != 3 {
t.Error("Expected", 3)
}
if Constrain32(0, math.MinInt32, math.MaxInt32) != 0 {
t.Error("Expected", 0)
}
}
func TestAsUint16(t *testing.T) {
if AsUint16(5) != 5 {
t.Error("Expected", 5)
@@ -120,18 +40,6 @@ func TestAsUint16(t *testing.T) {
}
}
func TestDurWithIn(t *testing.T) {
if DurWithin(time.Duration(5), time.Duration(1), time.Duration(8)) != time.Duration(5) {
t.Error("Expected", time.Duration(0))
}
if DurWithin(time.Duration(0)*time.Second, time.Second, time.Duration(3)*time.Second) != time.Second {
t.Error("Expected", time.Second)
}
if DurWithin(time.Duration(10)*time.Second, time.Duration(0), time.Second) != time.Second {
t.Error("Expected", time.Second)
}
}
func TestOnce(t *testing.T) {
o := Once(false)
if o() {
+3 -2
View File
@@ -9,6 +9,7 @@ import (
"strings"
"syscall"
"github.com/junegunn/go-shellwords"
"golang.org/x/sys/unix"
)
@@ -20,8 +21,8 @@ type Executor struct {
func NewExecutor(withShell string) *Executor {
shell := os.Getenv("SHELL")
args := strings.Fields(withShell)
if len(args) > 0 {
args, err := shellwords.Parse(withShell)
if err == nil && len(args) > 0 {
shell = args[0]
args = args[1:]
} else {
+44 -7
View File
@@ -11,6 +11,8 @@ import (
"strings"
"sync/atomic"
"syscall"
"golang.org/x/sys/windows"
)
type shellType int
@@ -19,6 +21,7 @@ const (
shellTypeUnknown shellType = iota
shellTypeCmd
shellTypePowerShell
shellTypePwsh
)
var escapeRegex = regexp.MustCompile(`[&|<>()^%!"]`)
@@ -46,7 +49,10 @@ func NewExecutor(withShell string) *Executor {
} else if strings.HasPrefix(basename, "cmd") {
shellType = shellTypeCmd
args = []string{"/s/c"}
} else if strings.HasPrefix(basename, "pwsh") || strings.HasPrefix(basename, "powershell") {
} else if strings.HasPrefix(basename, "pwsh") {
shellType = shellTypePwsh
args = []string{"-NoProfile", "-Command"}
} else if strings.HasPrefix(basename, "powershell") {
shellType = shellTypePowerShell
args = []string{"-NoProfile", "-Command"}
} else {
@@ -56,8 +62,12 @@ func NewExecutor(withShell string) *Executor {
}
// ExecCommand executes the given command with $SHELL
// FIXME: setpgid is unused. We set it in the Unix implementation so that we
// can kill preview process with its child processes at once.
//
// On Windows, setpgid controls whether the spawned process is placed in a new
// process group (so that it can be signaled independently, e.g. for previews).
// However, we only do this for "pwsh" and non-standard shells, because cmd.exe
// and Windows PowerShell ("powershell.exe") don't always exit on Ctrl-Break.
//
// NOTE: For "powershell", we should ideally set output encoding to UTF8,
// but it is left as is now because no adverse effect has been observed.
func (x *Executor) ExecCommand(command string, setpgid bool) *exec.Cmd {
@@ -73,19 +83,31 @@ func (x *Executor) ExecCommand(command string, setpgid bool) *exec.Cmd {
}
x.shellPath.Store(shell)
}
var creationFlags uint32
// Set new process group for pwsh (PowerShell 7+) and unknown/posix-ish shells
if setpgid && (x.shellType == shellTypePwsh || x.shellType == shellTypeUnknown) {
creationFlags = windows.CREATE_NEW_PROCESS_GROUP
}
var cmd *exec.Cmd
if x.shellType == shellTypeCmd {
cmd = exec.Command(shell)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: false,
CmdLine: fmt.Sprintf(`%s "%s"`, strings.Join(x.args, " "), command),
CreationFlags: 0,
CreationFlags: creationFlags,
}
} else {
cmd = exec.Command(shell, append(x.args, command)...)
args := x.args
if setpgid && x.shellType == shellTypePwsh {
// pwsh needs -NonInteractive flag to exit on Ctrl-Break
args = append([]string{"-NonInteractive"}, x.args...)
}
cmd = exec.Command(shell, append(args, command)...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: false,
CreationFlags: 0,
CreationFlags: creationFlags,
}
}
return cmd
@@ -156,7 +178,7 @@ func (x *Executor) QuoteEntry(entry string) string {
fd -H --no-ignore -td -d 4 | fzf --preview ".\eza.exe --color=always --tree --level=3 --icons=always {}" --with-shell "powershell -NoProfile -Command"
*/
return escapeArg(entry)
case shellTypePowerShell:
case shellTypePowerShell, shellTypePwsh:
escaped := strings.ReplaceAll(entry, `"`, `\"`)
return "'" + strings.ReplaceAll(escaped, "'", "''") + "'"
default:
@@ -166,6 +188,21 @@ func (x *Executor) QuoteEntry(entry string) string {
// KillCommand kills the process for the given command
func KillCommand(cmd *exec.Cmd) error {
// Safely handle nil command or process.
if cmd == nil || cmd.Process == nil {
return nil
}
// If it has its own process group, we can send it Ctrl-Break
if cmd.SysProcAttr != nil && cmd.SysProcAttr.CreationFlags&windows.CREATE_NEW_PROCESS_GROUP != 0 {
if err := windows.GenerateConsoleCtrlEvent(windows.CTRL_BREAK_EVENT, uint32(cmd.Process.Pid)); err == nil {
return nil
}
}
// If it's the same process group, or if sending the console control event
// fails (e.g., no console, different console, or process already exited),
// fall back to a standard kill. This probably won't *help* if there's I/O
// going on, because Wait() will still hang until the I/O finishes unless we
// hard-kill the entire process group. But it doesn't hurt to try!
return cmd.Process.Kill()
}
+41
View File
@@ -0,0 +1,41 @@
package fzf
import (
"os/exec"
)
func runZellij(args []string, opts *Options) (int, error) {
argStr, dir := popupArgStr(args, opts)
zellijArgs := []string{
"run", "--floating", "--close-on-exit", "--block-until-exit",
"--cwd", dir,
}
if !opts.Tmux.border {
zellijArgs = append(zellijArgs, "--borderless", "true")
}
switch opts.Tmux.position {
case posUp:
zellijArgs = append(zellijArgs, "-y", "0")
case posDown:
zellijArgs = append(zellijArgs, "-y", "9999")
case posLeft:
zellijArgs = append(zellijArgs, "-x", "0")
case posRight:
zellijArgs = append(zellijArgs, "-x", "9999")
case posCenter:
// Zellij centers floating panes by default
}
zellijArgs = append(zellijArgs, "--width", opts.Tmux.width.String())
zellijArgs = append(zellijArgs, "--height", opts.Tmux.height.String())
zellijArgs = append(zellijArgs, "--")
return runProxy(argStr, func(temp string, needBash bool) (*exec.Cmd, error) {
sh, err := sh(needBash)
if err != nil {
return nil, err
}
zellijArgs = append(zellijArgs, sh, temp)
return exec.Command("zellij", zellijArgs...), nil
}, opts, true)
}
+16
View File
@@ -0,0 +1,16 @@
# Unset fzf variables
set -e FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS FZF_DEFAULT_OPTS_FILE FZF_TMUX FZF_TMUX_OPTS
set -e FZF_CTRL_T_COMMAND FZF_CTRL_T_OPTS FZF_ALT_C_COMMAND FZF_ALT_C_OPTS FZF_CTRL_R_OPTS
set -e FZF_API_KEY
# Unset completion-specific variables
set -e FZF_COMPLETION_OPTS FZF_EXPANSION_OPTS
set -gx FZF_DEFAULT_OPTS "--no-scrollbar --pointer '>' --marker '>'"
set -gx fish_history fzf_test
# Add fzf to PATH
fish_add_path <%= BASE %>/bin
# Source key bindings and completion
source "<%= BASE %>/shell/key-bindings.fish"
source "<%= BASE %>/shell/completion.fish"
+95 -2
View File
@@ -11,6 +11,7 @@ require 'net/http'
require 'json'
TEMPLATE = File.read(File.expand_path('common.sh', __dir__))
FISH_TEMPLATE = File.read(File.expand_path('common.fish', __dir__))
UNSETS = %w[
FZF_DEFAULT_COMMAND FZF_DEFAULT_OPTS
FZF_TMUX FZF_TMUX_OPTS
@@ -24,7 +25,7 @@ DEFAULT_TIMEOUT = 10
FILE = File.expand_path(__FILE__)
BASE = File.expand_path('../..', __dir__)
Dir.chdir(BASE)
FZF = %[FZF_DEFAULT_OPTS="--no-scrollbar --gutter ' ' --pointer '>' --marker '>'" FZF_DEFAULT_COMMAND= #{BASE}/bin/fzf].freeze
FZF = %(FZF_DEFAULT_OPTS="--no-scrollbar --gutter ' ' --pointer '>' --marker '>'" FZF_DEFAULT_COMMAND= #{BASE}/bin/fzf).freeze
def wait(timeout = DEFAULT_TIMEOUT)
since = Time.now
@@ -66,7 +67,16 @@ class Shell
end
def fish
"unset #{UNSETS.join(' ')}; rm -f ~/.local/share/fish/fzf_test_history; FZF_DEFAULT_OPTS=\"--no-scrollbar --pointer '>' --marker '>'\" fish_history=fzf_test fish"
@fish ||=
begin
confdir = '/tmp/fzf-fish'
FileUtils.rm_rf(confdir)
FileUtils.mkdir_p("#{confdir}/fish/conf.d")
File.open("#{confdir}/fish/conf.d/fzf.fish", 'w') do |f|
f.puts ERB.new(FISH_TEMPLATE).result(binding)
end
"rm -f ~/.local/share/fish/fzf_test_history; XDG_CONFIG_HOME=#{confdir} fish"
end
end
end
end
@@ -95,6 +105,23 @@ class Tmux
go(%W[send-keys -t #{win}] + args.map(&:to_s))
end
# Simulate a mouse click at the given 1-based column and row using the SGR mouse protocol
# (xterm mouse mode 1006, which fzf enables). The escape sequence is injected as literal
# keystrokes via tmux, and fzf parses it like a real terminal mouse event.
#
# tmux's own mouse handling intercepts these sequences when `set -g mouse on`, so we toggle
# mouse off for the duration of the click and restore the previous state afterwards.
def click(col, row, button: 0)
prev = go(%w[show-options -gv mouse]).first
go(%w[set-option -g mouse off])
begin
seq = "\e[<#{button};#{col};#{row}M\e[<#{button};#{col};#{row}m"
go(%W[send-keys -t #{win} -l #{seq}])
ensure
go(%W[set-option -g mouse #{prev}]) if prev && !prev.empty?
end
end
def paste(str)
system('tmux', 'setb', str, ';', 'pasteb', '-t', win, ';', 'send-keys', '-t', win, 'Enter')
end
@@ -103,6 +130,71 @@ class Tmux
go(%W[capture-pane -p -J -t #{win}]).map(&:rstrip).reverse.drop_while(&:empty?).reverse
end
# Raw pane capture with ANSI escape sequences preserved.
def capture_ansi
go(%W[capture-pane -p -J -e -t #{win}])
end
# 3-bit ANSI bg code (40..47) -> color name used in --color options.
BG_NAMES = %w[black red green yellow blue magenta cyan white].freeze
# Parse `tmux capture-pane -e` output into per-row bg ranges. Each row is an
# array of [col_start, col_end, bg] tuples where bg is one of:
# 'default'
# 'red' / 'green' / 'blue' / ... (3-bit names)
# 'bright-red' / ... (bright variants)
# '256:<n>' (256-color fallback)
# ANSI state persists across rows, matching real terminal behavior.
def bg_ranges
raw = go(%W[capture-pane -p -J -e -t #{win}])
bg = 'default'
raw.map do |row|
cells = []
i = 0
len = row.length
while i < len
c = row[i]
if c == "\e" && row[i + 1] == '['
j = i + 2
j += 1 while j < len && row[j] != 'm'
parts = row[i + 2...j].split(';')
k = 0
while k < parts.length
p = parts[k].to_i
case p
when 0, 49 then bg = 'default'
when 40..47 then bg = BG_NAMES[p - 40]
when 100..107 then bg = "bright-#{BG_NAMES[p - 100]}"
when 48
if parts[k + 1] == '5'
bg = "256:#{parts[k + 2]}"
k += 2
elsif parts[k + 1] == '2'
bg = "rgb:#{parts[k + 2]}:#{parts[k + 3]}:#{parts[k + 4]}"
k += 4
end
end
k += 1
end
i = j + 1
else
cells << bg
i += 1
end
end
ranges = []
start = 0
cells.each_with_index do |b, idx|
if idx.positive? && b != cells[idx - 1]
ranges << [start, idx - 1, cells[idx - 1]]
start = idx
end
end
ranges << [start, cells.length - 1, cells.last] unless cells.empty?
ranges
end
end
def until(refresh = false, timeout: DEFAULT_TIMEOUT)
lines = nil
begin
@@ -153,6 +245,7 @@ class Tmux
self.until(true) do |lines|
message = "Prepare[#{tries}]"
send_keys ' ', 'C-u', :Enter, message, :Left, :Right
sleep(0.15)
lines[-1] == message
end
rescue Minitest::Assertion
+595 -11
View File
@@ -404,11 +404,11 @@ class TestCore < TestInteractive
tmux.send_keys "seq 1 111 | #{fzf("-m +s --tac #{opt} -q11")}", :Enter
tmux.until { |lines| assert_equal '> 111', lines[-3] }
tmux.send_keys :Tab
tmux.until { |lines| assert_equal ' 4/111 -S (1)', lines[-2] }
tmux.until { |lines| assert_equal ' 4/111 (1) -S', lines[-2] }
tmux.send_keys 'C-R'
tmux.until { |lines| assert_equal '> 11', lines[-3] }
tmux.send_keys :Tab
tmux.until { |lines| assert_equal ' 4/111 +S (2)', lines[-2] }
tmux.until { |lines| assert_equal ' 4/111 (2) +S', lines[-2] }
tmux.send_keys :Enter
assert_equal %w[111 11], fzf_output_lines
end
@@ -1190,6 +1190,71 @@ class TestCore < TestInteractive
tmux.until { |lines| assert lines.any_include?('9999␊10000') }
end
def test_freeze_left_tabstop
writelines(%W[1\t2\t3])
# With --freeze-left 1 and --tabstop=2:
# Frozen left: "1" (width 1)
# Middle starts with "\t" at prefix width 1, tabstop 2 → 1 space
# Then "2" at column 2, next "\t" at column 3 → 1 space, then "3"
tmux.send_keys %(cat #{tempname} | #{FZF} --tabstop=2 --freeze-left 1), :Enter
tmux.until { |lines| assert_equal '> 1 2 3', lines[-3] }
end
def test_freeze_left_keep_right
tmux.send_keys %(seq 10000 | #{FZF} --read0 --delimiter "\n" --freeze-left 3 --keep-right --ellipsis XX --no-multi-line --bind space:toggle-multi-line), :Enter
tmux.until { |lines| assert_match(/^> 1␊2␊3XX.*10000␊$/, lines[-3]) }
tmux.send_keys '5'
tmux.until { |lines| assert_match(/^> 1␊2␊3␊4␊5␊.*XX$/, lines[-3]) }
tmux.send_keys :Space
tmux.until { |lines| assert lines.any_include?('> 1') }
tmux.send_keys :Space
tmux.until { |lines| assert lines.any_include?('1␊2␊3␊4␊5␊') }
end
def test_freeze_left_and_right
tmux.send_keys %(seq 10000 | tr "\n" ' ' | #{FZF} --freeze-left 3 --freeze-right 3 --ellipsis XX), :Enter
tmux.until { |lines| assert_match(/XX9998 9999 10000$/, lines[-3]) }
tmux.send_keys "'1000"
tmux.until { |lines| assert_match(/^> 1 2 3XX.*XX9998 9999 10000$/, lines[-3]) }
end
def test_freeze_left_and_right_delimiter
tmux.send_keys %(seq 10000 | tr "\n" ' ' | sed 's/ / , /g' | #{FZF} --freeze-left 3 --freeze-right 3 --ellipsis XX --delimiter ' , '), :Enter
tmux.until { |lines| assert_match(/XX, 9999 , 10000 ,$/, lines[-3]) }
tmux.send_keys "'1000"
tmux.until { |lines| assert_match(/^> 1 , 2 , 3 ,XX.*XX, 9999 , 10000 ,$/, lines[-3]) }
end
def test_freeze_right_exceed_range
tmux.send_keys %(seq 10000 | tr "\n" ' ' | #{FZF} --freeze-right 100000 --ellipsis XX), :Enter
['', "'1000"].each do |query|
tmux.send_keys query
tmux.until { |lines| assert lines.any_include?("> #{query}".strip) }
tmux.until do |lines|
assert_match(/ 9998 9999 10000$/, lines[-3])
assert_equal(1, lines[-3].scan('XX').size)
end
end
end
def test_freeze_right_exceed_range_with_freeze_left
tmux.send_keys %(seq 10000 | tr "\n" ' ' | #{FZF} --freeze-left 3 --freeze-right 100000 --ellipsis XX), :Enter
tmux.until do |lines|
assert_match(/^> 1 2 3XX.*9998 9999 10000$/, lines[-3])
assert_equal(1, lines[-3].scan('XX').size)
end
end
def test_freeze_right_with_ellipsis_and_scrolling
tmux.send_keys "{ seq 6; ruby -e 'print \"g\"*1000, \"\\n\"'; seq 8 100; } | #{FZF} --ellipsis='777' --freeze-right 1 --scroll-off 0 --bind a:offset-up", :Enter
tmux.until { |lines| assert_equal ' 100/100', lines[-2] }
tmux.send_keys(*Array.new(6) { :a })
tmux.until do |lines|
assert_match(/> 777g+$/, lines[-3])
assert_equal(1, lines.count { |l| l.end_with?('g') })
end
end
def test_backward_eof
tmux.send_keys "echo foo | #{FZF} --bind 'backward-eof:reload(seq 100)'", :Enter
tmux.until { |lines| lines.item_count == 1 && lines.match_count == 1 }
@@ -1533,14 +1598,16 @@ class TestCore < TestInteractive
end
def test_track_action
tmux.send_keys "seq 1000 | #{FZF} --query 555 --bind t:track", :Enter
tmux.send_keys "seq 1000 | #{FZF} --pointer x --query 555 --bind t:track,T:up+track", :Enter
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_includes lines, 'x 555'
assert_includes lines, '> 555'
end
tmux.send_keys :BSpace
tmux.until do |lines|
assert_equal 28, lines.match_count
assert_includes lines, 'x 55'
assert_includes lines, '> 55'
end
tmux.send_keys :t
@@ -1550,7 +1617,8 @@ class TestCore < TestInteractive
tmux.send_keys :BSpace
tmux.until do |lines|
assert_equal 271, lines.match_count
assert_includes lines, '> 55'
assert_includes lines, 'x 55'
assert_includes lines, '> 5'
end
# Automatically disabled when the tracking item is no longer visible
@@ -1562,16 +1630,263 @@ class TestCore < TestInteractive
tmux.send_keys :BSpace
tmux.until do |lines|
assert_equal 271, lines.match_count
assert_includes lines, 'x 52'
assert_includes lines, '> 5'
end
tmux.send_keys :t
tmux.until do |lines|
assert_includes lines[-2], '+t'
end
# Automatically disabled when the focus has moved
tmux.send_keys :Up
tmux.until do |lines|
assert_includes lines, 'x 53'
refute_includes lines[-2], '+t'
end
# Should work even when combined with a focus moving actions
tmux.send_keys 'T'
tmux.until do |lines|
assert_includes lines, 'x 54'
assert_includes lines[-2], '+t'
end
tmux.send_keys 'T'
tmux.until do |lines|
assert_includes lines, 'x 55'
assert_includes lines[-2], '+t'
end
end
def test_track_nth_reload_whole_line
# --track --id-nth .. should track by entire line across reloads
tmux.send_keys "seq 1000 | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload:seq 1000 | sort -R'", :Enter
tmux.until { |lines| assert_equal 1000, lines.match_count }
# Move to item 555
tmux.send_keys '555'
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_includes lines, '> 555'
end
tmux.send_keys :BSpace, :BSpace, :BSpace
# Reload with shuffled order - cursor should track "555"
tmux.send_keys 'C-r'
tmux.until do |lines|
assert_equal 1000, lines.match_count
assert_includes lines, '> 555'
assert_includes lines[-2], '+T'
refute_includes lines[-2], '+T*'
end
end
def test_track_nth_reload_field
# --track --id-nth 1 should track by first field across reloads
tmux.send_keys "printf '1 apple\\n2 banana\\n3 cherry\\n' | #{FZF} --track --id-nth 1 --bind 'ctrl-r:reload:printf \"1 apricot\\n2 blueberry\\n3 cranberry\\n\"'", :Enter
tmux.until do |lines|
assert_equal 3, lines.match_count
assert_includes lines, '> 1 apple'
end
# Move up to "2 banana"
tmux.send_keys :Up
tmux.until { |lines| assert_includes lines, '> 2 banana' }
# Reload - the second field changes, but first field "2" stays
tmux.send_keys 'C-r'
tmux.until do |lines|
assert_equal 3, lines.match_count
assert_includes lines, '> 2 blueberry'
end
end
def test_track_nth_reload_no_match
# When tracked item is not found after reload, cursor stays at current position
tmux.send_keys "printf 'alpha\\nbeta\\ngamma\\n' | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload:printf \"delta\\nepsilon\\nzeta\\n\"'", :Enter
tmux.until { |lines| assert_equal 3, lines.match_count }
tmux.send_keys :Up
tmux.until { |lines| assert_includes lines, '> beta' }
# Reload with completely different items - no match for "beta"
# Cursor stays at the same position (second item)
tmux.send_keys 'C-r'
tmux.until do |lines|
assert_equal 3, lines.match_count
assert_includes lines, '> epsilon'
refute_includes lines[-2], '+T*'
end
end
def test_track_nth_blocked_indicator
# +T* should appear during reload and disappear when match is found
tmux.send_keys "seq 100 | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload:sleep 1; seq 100 | sort -R'", :Enter
tmux.until do |lines|
assert_equal 100, lines.match_count
assert_includes lines[-2], '+T'
end
# Trigger slow reload - should show +T* while blocked
tmux.send_keys 'C-r'
tmux.until { |lines| assert_includes lines[-2], '+T*' }
# After reload completes, +T* should clear back to +T
tmux.until do |lines|
assert_equal 100, lines.match_count
assert_includes lines[-2], '+T'
refute_includes lines[-2], '+T*'
end
end
def test_track_nth_abort_unblocks
# Escape during track-blocked state should unblock, not quit
tmux.send_keys "seq 100 | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload:sleep 3; seq 100'", :Enter
tmux.until do |lines|
assert_equal 100, lines.match_count
assert_includes lines[-2], '+T'
end
# Trigger slow reload
tmux.send_keys 'C-r'
tmux.until { |lines| assert_includes lines[-2], '+T*' }
# Escape should unblock, not quit fzf
tmux.send_keys :Escape
tmux.until do |lines|
assert_includes lines[-2], '+T'
refute_includes lines[-2], '+T*'
end
end
def test_track_nth_reload_async_unblocks_early
# With async reload, +T* should clear as soon as the match streams in,
# even while loading is still in progress.
# sleep 1 first so +T* is observable, then the match arrives, then more items after a delay.
tmux.send_keys "seq 5 | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload:sleep 1; echo 1; sleep 2; seq 2 10'", :Enter
tmux.until do |lines|
assert_equal 5, lines.match_count
assert_includes lines, '> 1'
end
# Trigger reload - blocked during initial sleep
tmux.send_keys 'C-r'
tmux.until { |lines| assert_includes lines[-2], '+T*' }
# Match "1" arrives, unblocks before the remaining items load
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_includes lines, '> 1'
assert_includes lines[-2], '+T'
refute_includes lines[-2], '+T*'
end
end
def test_track_nth_reload_sync_blocks_until_complete
# With reload-sync, +T* should stay until the entire stream is complete,
# even though the match arrives early in the stream.
tmux.send_keys "seq 5 | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload-sync:sleep 1; echo 1; sleep 2; seq 2 10'", :Enter
tmux.until do |lines|
assert_equal 5, lines.match_count
assert_includes lines, '> 1'
end
# Trigger reload-sync - every observable state must be either:
# 1. +T* (still blocked), or
# 2. final state (count=10, +T without *)
# Any other combination (e.g. unblocked while count < 10) is a bug.
tmux.send_keys 'C-r'
tmux.until do |lines|
info = lines[-2]
blocked = info&.include?('+T*')
unless blocked
raise "Unblocked before stream complete (count: #{lines.match_count})" if lines.match_count != 10
assert_includes info, '+T'
assert_includes lines, '> 1'
end
!blocked
end
end
def test_track_nth_toggle_track_unblocks
# toggle-track during track-blocked state should unblock and disable tracking
tmux.send_keys "seq 100 | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload:sleep 5; seq 100' --bind 'ctrl-t:toggle-track'", :Enter
tmux.until do |lines|
assert_equal 100, lines.match_count
assert_includes lines[-2], '+T'
end
# Trigger slow reload
tmux.send_keys 'C-r'
tmux.until { |lines| assert_includes lines[-2], '+T*' }
# toggle-track should unblock and disable tracking before reload completes
tmux.send_keys 'C-t'
tmux.until(timeout: 3) do |lines|
refute_includes lines[-2], '+T'
end
end
def test_track_nth_reload_async_no_match
# With async reload, when tracked item is not found, cursor stays at
# current position after stream completes
tmux.send_keys "printf 'alpha\\nbeta\\ngamma\\n' | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload:sleep 1; printf \"delta\\nepsilon\\nzeta\\n\"'", :Enter
tmux.until { |lines| assert_equal 3, lines.match_count }
tmux.send_keys :Up
tmux.until { |lines| assert_includes lines, '> beta' }
# Reload with completely different items - no match for "beta"
tmux.send_keys 'C-r'
tmux.until { |lines| assert_includes lines[-2], '+T*' }
# After stream completes, unblocks with cursor at same position (second item)
tmux.until do |lines|
assert_equal 3, lines.match_count
assert_includes lines, '> epsilon'
refute_includes lines[-2], '+T*'
end
end
def test_track_action_with_id_nth
# track-current with --id-nth should track by specified field
tmux.send_keys "printf '1 apple\\n2 banana\\n3 cherry\\n' | #{FZF} --id-nth 1 --bind 'ctrl-t:track-current,ctrl-r:reload:printf \"1 apricot\\n2 blueberry\\n3 cranberry\\n\"'", :Enter
tmux.until { |lines| assert_equal 3, lines.match_count }
# Move to "2 banana" and activate tracking
tmux.send_keys :Up
tmux.until { |lines| assert_includes lines, '> 2 banana' }
tmux.send_keys 'C-t'
tmux.until { |lines| assert_includes lines[-2], '+t' }
# Reload - should track by field "2"
tmux.send_keys 'C-r'
tmux.until do |lines|
assert_equal 3, lines.match_count
assert_includes lines, '> 2 blueberry'
end
end
def test_id_nth_preserve_multi_selection
# --id-nth with --multi should preserve selections across reload-sync
File.write(tempname, "1 apricot\n2 blueberry\n3 cranberry\n")
tmux.send_keys "printf '1 apple\\n2 banana\\n3 cherry\\n' | #{fzf("--multi --id-nth 1 --bind 'ctrl-r:reload-sync:cat #{tempname}'")}", :Enter
tmux.until { |lines| assert_equal 3, lines.match_count }
# Select first item (1 apple) and third item (3 cherry)
tmux.send_keys :Tab
tmux.send_keys :Up, :Up, :Tab
tmux.until { |lines| assert_includes lines[-2], '(2)' }
# Reload - selections should be preserved by id-nth key
tmux.send_keys 'C-r'
tmux.until do |lines|
assert_equal 3, lines.match_count
assert_includes lines[-2], '(2)'
assert(lines.any? { |l| l.include?('apricot') })
end
# Accept and verify the correct items were preserved
tmux.send_keys :Enter
assert_equal ['1 apricot', '3 cranberry'], fzf_output_lines
end
def test_one_and_zero
@@ -1670,6 +1985,191 @@ class TestCore < TestInteractive
end
end
def test_change_with_nth
input = [
'foo bar baz',
'aaa bbb ccc',
'xxx yyy zzz'
]
writelines(input)
# Start with field 1 only, cycle through fields, verify $FZF_WITH_NTH via prompt
tmux.send_keys %(#{FZF} --with-nth 1 --bind 'space:change-with-nth(2|3|1),result:transform-prompt:echo "[$FZF_WITH_NTH]> "' < #{tempname}), :Enter
tmux.until do |lines|
assert_equal 3, lines.item_count
assert lines.any_include?('[1]>')
assert lines.any_include?('foo')
refute lines.any_include?('bar')
end
tmux.send_keys :Space
tmux.until do |lines|
assert lines.any_include?('[2]>')
assert lines.any_include?('bar')
refute lines.any_include?('foo')
end
tmux.send_keys :Space
tmux.until do |lines|
assert lines.any_include?('[3]>')
assert lines.any_include?('baz')
refute lines.any_include?('bar')
end
tmux.send_keys :Space
tmux.until do |lines|
assert lines.any_include?('[1]>')
assert lines.any_include?('foo')
refute lines.any_include?('bar')
end
end
def test_change_with_nth_default
# Empty value restores the default --with-nth
tmux.send_keys %(echo -e 'a b c\nd e f' | #{FZF} --with-nth 1 --bind 'space:change-with-nth(2|)'), :Enter
tmux.until do |lines|
assert_equal 2, lines.item_count
assert lines.any_include?('a')
refute lines.any_include?('b')
end
# Switch to field 2
tmux.send_keys :Space
tmux.until do |lines|
assert lines.any_include?('b')
refute lines.any_include?('a')
end
# Empty restores default (field 1)
tmux.send_keys :Space
tmux.until do |lines|
assert lines.any_include?('a')
refute lines.any_include?('b')
end
end
def test_transform_with_nth_search
input = [
'alpha bravo charlie',
'delta echo foxtrot',
'golf hotel india'
]
writelines(input)
tmux.send_keys %(#{FZF} --with-nth 1 --bind 'space:transform-with-nth(echo 2)' -q '^bravo$' < #{tempname}), :Enter
tmux.until do |lines|
assert_equal 0, lines.match_count
end
tmux.send_keys :Space
tmux.until do |lines|
assert_equal 1, lines.match_count
end
end
def test_bg_transform_with_nth_output
tmux.send_keys %(echo -e 'a b c\nd e f' | #{FZF} --with-nth 2 --bind 'space:bg-transform-with-nth(echo 3)'), :Enter
tmux.until do |lines|
assert_equal 2, lines.item_count
assert lines.any_include?('b')
end
tmux.send_keys :Space
tmux.until do |lines|
assert lines.any_include?('c')
refute lines.any_include?('b')
end
tmux.send_keys :Enter
tmux.until { |lines| assert lines.any_include?('a b c') || lines.any_include?('d e f') }
end
def test_change_with_nth_search
input = [
'alpha bravo charlie',
'delta echo foxtrot',
'golf hotel india'
]
writelines(input)
tmux.send_keys %(#{FZF} --with-nth 1 --bind 'space:change-with-nth(2)' -q '^bravo$' < #{tempname}), :Enter
tmux.until do |lines|
assert_equal 0, lines.match_count
end
tmux.send_keys :Space
tmux.until do |lines|
assert_equal 1, lines.match_count
end
end
def test_change_with_nth_output
tmux.send_keys %(echo -e 'a b c\nd e f' | #{FZF} --with-nth 2 --bind 'space:change-with-nth(3)'), :Enter
tmux.until do |lines|
assert_equal 2, lines.item_count
assert lines.any_include?('b')
end
tmux.send_keys :Space
tmux.until do |lines|
assert lines.any_include?('c')
refute lines.any_include?('b')
end
tmux.send_keys :Enter
tmux.until { |lines| assert lines.any_include?('a b c') || lines.any_include?('d e f') }
end
def test_change_with_nth_selection
# Items: field1 has unique values, field2 has 'match' or 'miss'
input = [
'one match x',
'two miss y',
'three match z'
]
writelines(input)
# Start showing field 2 (match/miss), query 'match', select all matches, then switch to field 3
tmux.send_keys %(#{FZF} --with-nth 2 --multi --bind 'ctrl-a:select-all,space:change-with-nth(3)' -q match < #{tempname}), :Enter
tmux.until do |lines|
assert_equal 2, lines.match_count
end
# Select all matching items
tmux.send_keys 'C-a'
tmux.until do |lines|
assert lines.any_include?('(2)')
end
# Now change with-nth to field 3; 'x' and 'z' don't contain 'match'
tmux.send_keys :Space
tmux.until do |lines|
assert_equal 0, lines.match_count
# Selections of non-matching items should be cleared
assert lines.any_include?('(0)')
end
end
def test_change_with_nth_multiline
# Each item has 3 lines: "N-a\nN-b\nN-c"
# --with-nth 1 shows 1 line per item, --with-nth 1..3 shows 3 lines per item
tmux.send_keys %(seq 20 | xargs -I{} printf '{}-a\\n{}-b\\n{}-c\\0' | #{FZF} --read0 --delimiter "\n" --with-nth 1 --bind 'space:change-with-nth(1..3|1)' --no-sort), :Enter
tmux.until do |lines|
assert_equal 20, lines.item_count
assert lines.any_include?('1-a')
refute lines.any_include?('1-b')
end
# Expand to 3 lines per item
tmux.send_keys :Space
tmux.until do |lines|
assert lines.any_include?('1-a')
assert lines.any_include?('1-b')
assert lines.any_include?('1-c')
end
# Scroll down a few items
5.times { tmux.send_keys :Down }
tmux.until do |lines|
assert lines.any_include?('6-a')
assert lines.any_include?('6-b')
assert lines.any_include?('6-c')
end
# Collapse back to 1 line per item
tmux.send_keys :Space
tmux.until do |lines|
assert lines.any_include?('6-a')
refute lines.any_include?('6-b')
end
# Scroll down more after collapse
5.times { tmux.send_keys :Down }
tmux.until do |lines|
assert lines.any_include?('11-a')
refute lines.any_include?('11-b')
end
end
def test_env_vars
def env_vars
return {} unless File.exist?(tempname)
@@ -1680,8 +2180,9 @@ class TestCore < TestInteractive
end
end
tmux.send_keys %(seq 100 | #{FZF} --multi --reverse --preview-window 0 --preview 'env | grep ^FZF_ | sort > #{tempname}' --no-input --bind enter:show-input+refresh-preview,space:disable-search+refresh-preview), :Enter
tmux.send_keys %({ echo foo; seq 100; } | #{FZF} --header-lines 1 --multi --reverse --preview-window 0 --preview 'env | grep ^FZF_ | sort > #{tempname}' --no-input --bind enter:show-input+refresh-preview,space:disable-search+refresh-preview), :Enter
expected = {
FZF_DIRECTION: 'down',
FZF_TOTAL_COUNT: '100',
FZF_MATCH_COUNT: '100',
FZF_SELECT_COUNT: '0',
@@ -1824,13 +2325,13 @@ class TestCore < TestInteractive
tmux.send_keys %(echo "foo ,bar,baz" | #{FZF} -d, --accept-nth 2,2,1,3,1 --sync --bind start:accept > #{tempname}), :Enter
wait do
assert_path_exists tempname
# Last delimiter and the whitespaces are removed
assert_equal ['bar,bar,foo ,bazfoo'], File.readlines(tempname, chomp: true)
# Last delimiter is removed
assert_equal ['bar,bar,foo ,bazfoo '], File.readlines(tempname, chomp: true)
end
end
def test_accept_nth_regex_delimiter
tmux.send_keys %(echo "foo :,:bar,baz" | #{FZF} --delimiter='[:,]+' --accept-nth 2,2,1,3,1 --sync --bind start:accept > #{tempname}), :Enter
tmux.send_keys %(echo "foo :,:bar,baz" | #{FZF} --delimiter=' *[:,]+ *' --accept-nth 2,2,1,3,1 --sync --bind start:accept > #{tempname}), :Enter
wait do
assert_path_exists tempname
# Last delimiter and the whitespaces are removed
@@ -1848,7 +2349,7 @@ class TestCore < TestInteractive
end
def test_accept_nth_template
tmux.send_keys %(echo "foo ,bar,baz" | #{FZF} -d, --accept-nth '[{n}] 1st: {1}, 3rd: {3}, 2nd: {2}' --sync --bind start:accept > #{tempname}), :Enter
tmux.send_keys %(echo "foo ,bar,baz" | #{FZF} -d " *, *" --accept-nth '[{n}] 1st: {1}, 3rd: {3}, 2nd: {2}' --sync --bind start:accept > #{tempname}), :Enter
wait do
assert_path_exists tempname
# Last delimiter and the whitespaces are removed
@@ -2004,7 +2505,7 @@ class TestCore < TestInteractive
end
end
elapsed = Time.now - time
assert elapsed < 2
assert_operator elapsed, :<, 2
end
def test_bg_cancel
@@ -2017,7 +2518,7 @@ class TestCore < TestInteractive
tmux.until { assert_equal 2, it.match_count }
tmux.send_keys :Space
tmux.until { |lines| assert lines.any_include?('[0]') }
sleep 2
sleep(2)
tmux.until do |lines|
assert lines.any_include?('[0]')
refute lines.any_include?('[1]')
@@ -2099,4 +2600,87 @@ class TestCore < TestInteractive
assert_equal 1, it.match_count
end
end
def test_change_header_lines
tmux.send_keys %(seq 10 | #{FZF} --header-lines 3 --bind 'space:change-header-lines(5),enter:transform-header-lines(echo 1)'), :Enter
tmux.until do |lines|
assert_equal 7, lines.item_count
assert lines.any_include?('> 4')
end
tmux.send_keys :Space
tmux.until do |lines|
assert_equal 5, lines.item_count
assert lines.any_include?('> 6')
end
tmux.send_keys :Enter
tmux.until do |lines|
assert_equal 9, lines.item_count
assert lines.any_include?('> 6')
end
end
def test_change_header_lines_to_zero
tmux.send_keys %(seq 5 | #{FZF} --header-lines 3 --bind 'space:bg-transform-header-lines(echo 0)'), :Enter
tmux.until do |lines|
assert_equal 2, lines.item_count
assert lines.any_include?('> 4')
end
tmux.send_keys :Space
tmux.until do |lines|
assert_equal 5, lines.item_count
# All items are now in the list, cursor stays on item 4
assert lines.any_include?('> 4')
end
end
def test_change_header_lines_deselect
# Selected items that become part of the header should be deselected
tmux.send_keys %(seq 10 | #{FZF} --multi --header-lines 0 --bind 'space:change-header-lines(3),enter:change-header-lines(1)'), :Enter
tmux.until do |lines|
assert_equal 10, lines.item_count
assert lines.any_include?('> 1')
end
# Select items 1, 2, 3 (these will become header lines)
tmux.send_keys :BTab, :BTab, :BTab
tmux.until { |lines| assert_equal 3, lines.select_count }
# Also select item 4 (this should remain selected)
tmux.send_keys :BTab
tmux.until { |lines| assert_equal 4, lines.select_count }
# Change header-lines to 3: items 1, 2, 3 become headers and should be deselected
tmux.send_keys :Space
tmux.until do |lines|
assert_equal 7, lines.item_count
assert_equal 1, lines.select_count
assert lines.any_include?('> 5')
end
# Change header-lines to 1
tmux.send_keys :Enter
tmux.until do |lines|
assert_equal 9, lines.item_count
assert_equal 1, lines.select_count
assert lines.any_include?('> 5')
end
end
def test_change_header_lines_reverse
tmux.send_keys %(seq 10 | #{FZF} --header-lines 2 --reverse --bind 'space:change-header-lines(4)'), :Enter
tmux.until do |lines|
assert_equal 8, lines.item_count
assert lines.any_include?('> 3')
end
tmux.send_keys :Space
tmux.until do |lines|
assert_equal 6, lines.item_count
assert lines.any_include?('> 5')
end
end
def test_zero_width_characters
tmux.send_keys %(for i in {1..1000}; do string+="a̱$i"; printf '\\e[43m%s\\e[0m\\n' "$string"; done | #{FZF} --ansi --query a500 --ellipsis XX), :Enter
tmux.until do |lines|
assert_equal 981, lines.match_count
assert_match(/^> XX.*a̱500/, lines[-3])
assert(lines.reverse.drop(5).all? { it.match?(/^ XX.*a̱500.*XX/) })
end
end
end
+37
View File
@@ -312,4 +312,41 @@ class TestFilter < TestBase
assert_equal expected, result
end
end
def test_accept_nth
# Single field selection
assert_equal 'three', `echo 'one two three' | #{FZF} -d' ' --with-nth 1 --accept-nth -1 -f one`.chomp
# Multiple field selection
writelines(['ID001:John:Developer', 'ID002:Jane:Manager', 'ID003:Bob:Designer'])
assert_equal 'ID001', `#{FZF} -d: --with-nth 2 --accept-nth 1 -f John < #{tempname}`.chomp
assert_equal 'ID002:Manager', `#{FZF} -d: --with-nth 2 --accept-nth 1,3 -f Jane < #{tempname}`.chomp
# Test with different delimiters
writelines(['emp001 Alice Engineering', 'emp002 Bob Marketing'])
assert_equal 'emp001', `#{FZF} -d' ' --with-nth 2 --accept-nth 1 -f Alice < #{tempname}`.chomp
end
def test_header_lines_filter
assert_equal %w[4 5 6 7 8 9 10],
`seq 10 | #{FZF} --header-lines 3 -f ""`.lines(chomp: true)
assert_equal %w[5],
`seq 10 | #{FZF} --header-lines 3 -f 5`.lines(chomp: true)
# Header items should not be matched
assert_empty `seq 10 | #{FZF} --header-lines 3 -f "^1$"`.lines(chomp: true)
end
def test_header_lines_filter_with_nth
writelines(%w[a:1 b:2 c:3 d:4 e:5])
assert_equal %w[c:3 d:4 e:5],
`#{FZF} --header-lines 2 -d: --with-nth 2 -f "" < #{tempname}`.lines(chomp: true)
assert_equal %w[d:4],
`#{FZF} --header-lines 2 -d: --with-nth 2 -f 4 < #{tempname}`.lines(chomp: true)
end
def test_header_lines_all_headers
# When all lines are header lines, no results
assert_empty `seq 3 | #{FZF} --header-lines 10 -f ""`.chomp
assert_equal 1, $CHILD_STATUS.exitstatus
end
end
+353
View File
@@ -1192,6 +1192,38 @@ class TestLayout < TestInteractive
tmux.until { assert_block(block, it) }
end
# https://github.com/junegunn/fzf/issues/4537
def test_no_scrollbar_preview_toggle
x = 'x' * 300
y = 'y' * 300
tmux.send_keys %(yes #{x} | head -1000 | fzf --bind 'tab:toggle-preview' --border --no-scrollbar --preview 'echo #{y}' --preview-window 'border-left'), :Enter
# │ ▌ xxxxxxxx·· │ yyyyyyyy│
tmux.until do |lines|
lines.any? { it.match?(/x·· │ y+│$/) }
end
tmux.send_keys :Tab
# │ ▌ xxxxxxxx·· │
tmux.until do |lines|
lines.none? { it.match?(/x··y│$/) }
end
tmux.send_keys :Tab
tmux.until do |lines|
lines.any? { it.match?(/x·· │ y+│$/) }
end
end
def test_header_and_footer_should_not_be_wider_than_list
tmux.send_keys %(WIDE=$(printf 'x%.0s' {1..1000}); (echo $WIDE; echo $WIDE) | fzf --header-lines 1 --style full --header-border bottom --header-lines-border top --ellipsis XX --header "$WIDE" --footer "$WIDE" --no-footer-border), :Enter
tmux.until do |lines|
matches = lines.filter_map { |line| line[/x+XX/] }
assert_equal 4, matches.length
assert_equal 1, matches.uniq.length
end
end
def test_combinations
skip unless ENV['LONGTEST']
@@ -1266,4 +1298,325 @@ class TestLayout < TestInteractive
tmux.send_keys :Enter
end
end
# Locate a word in the currently captured screen and click its first character.
# tmux rows/columns are 1-based; capture indices are 0-based.
def click_word(word)
tmux.capture.each_with_index do |line, idx|
col = line.index(word)
return tmux.click(col + 1, idx + 1) if col
end
flunk("word #{word.inspect} not found on screen")
end
# Launch fzf with a click-{header,footer} binding that echoes FZF_CLICK_* into the prompt,
# then click each word in `clicks` and assert the resulting L/W values.
# `clicks` is an array of [word_to_click, expected_line].
def verify_clicks(kind:, opts:, input:, clicks:)
var = kind.to_s.upcase # HEADER or FOOTER
binding = "click-#{kind}:transform-prompt:" \
"echo \"L=$FZF_CLICK_#{var}_LINE W=$FZF_CLICK_#{var}_WORD> \""
# --multi makes the info line end in " (0)" so the wait regex is unambiguous.
tmux.send_keys %(#{input} | #{FZF} #{opts} --multi --bind '#{binding}'), :Enter
# Wait for fzf to fully render before inspecting the screen, otherwise the echoed
# command line can shadow click targets.
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+ \(0\)}) }
clicks.each do |word, line|
click_word(word)
tmux.until { |lines| assert lines.any_include?("L=#{line} W=#{word}>") }
end
tmux.send_keys 'Escape'
end
# Header lines (--header-lines) are rendered in reverse display order only under
# layout=default; in layout=reverse and layout=reverse-list they keep the input order.
# FZF_CLICK_HEADER_LINE reflects the visual row, so the expected value flips.
HEADER_CLICKS = [%w[Aaa 1], %w[Bbb 2], %w[Ccc 3]].freeze
%w[default reverse reverse-list].each do |layout|
slug = layout.tr('-', '_')
# Plain --header with no border around the header section.
define_method(:"test_click_header_plain_#{slug}") do
verify_clicks(kind: :header,
opts: %(--layout=#{layout} --header $'Aaa\\nBbb\\nCcc'),
input: 'seq 5',
clicks: HEADER_CLICKS)
end
# --header with a framing border (--style full gives --header-border=rounded by default).
define_method(:"test_click_header_border_rounded_#{slug}") do
verify_clicks(kind: :header,
opts: %(--layout=#{layout} --style full --header $'Aaa\\nBbb\\nCcc'),
input: 'seq 5',
clicks: HEADER_CLICKS)
end
# --header-lines consumed from stdin, with its own framing border.
define_method(:"test_click_header_lines_border_rounded_#{slug}") do
clicks_hl = if layout == 'default'
[%w[Xaa 3], %w[Ybb 2], %w[Zcc 1]]
else
[%w[Xaa 1], %w[Ybb 2], %w[Zcc 3]]
end
verify_clicks(kind: :header,
opts: %(--layout=#{layout} --style full --header-lines 3),
input: "(printf 'Xaa\\nYbb\\nZcc\\n'; seq 5)",
clicks: clicks_hl)
end
# --footer with a framing border.
define_method(:"test_click_footer_border_rounded_#{slug}") do
verify_clicks(kind: :footer,
opts: %(--layout=#{layout} --style full --footer $'Foo\\nBar\\nBaz'),
input: 'seq 5',
clicks: [%w[Foo 1], %w[Bar 2], %w[Baz 3]])
end
# --header and --header-lines combined. Click-header numbering concatenates the two
# sections, but the order depends on the layout:
# layoutReverse: custom header (1..N), then header-lines (N+1..N+M)
# layoutDefault: header-lines (1..M, reversed visually), then custom header (M+1..M+N)
# layoutReverseList: header-lines (1..M), then custom header (M+1..M+N)
define_method(:"test_click_header_combined_#{slug}") do
clicks = case layout
when 'reverse'
[%w[Aaa 1], %w[Bbb 2], %w[Ccc 3], %w[Xaa 4], %w[Ybb 5], %w[Zcc 6]]
when 'default'
[%w[Aaa 4], %w[Bbb 5], %w[Ccc 6], %w[Xaa 3], %w[Ybb 2], %w[Zcc 1]]
else # reverse-list
[%w[Aaa 4], %w[Bbb 5], %w[Ccc 6], %w[Xaa 1], %w[Ybb 2], %w[Zcc 3]]
end
verify_clicks(kind: :header,
opts: %(--layout=#{layout} --header $'Aaa\\nBbb\\nCcc' --header-lines 3),
input: "(printf 'Xaa\\nYbb\\nZcc\\n'; seq 5)",
clicks: clicks)
end
# Inline header inside a rounded list border.
define_method(:"test_click_header_border_inline_#{slug}") do
opts = %(--layout=#{layout} --style full --header $'Aaa\\nBbb\\nCcc' --header-border=inline)
verify_clicks(kind: :header, opts: opts, input: 'seq 5', clicks: HEADER_CLICKS)
end
# Inline header inside a horizontal list border (top+bottom only, no T-junctions).
define_method(:"test_click_header_border_inline_horizontal_list_#{slug}") do
opts = %(--layout=#{layout} --style full --list-border=horizontal --header $'Aaa\\nBbb\\nCcc' --header-border=inline)
verify_clicks(kind: :header, opts: opts, input: 'seq 5', clicks: HEADER_CLICKS)
end
# Inline header-lines inside a rounded list border.
define_method(:"test_click_header_lines_border_inline_#{slug}") do
clicks_hl = if layout == 'default'
[%w[Xaa 3], %w[Ybb 2], %w[Zcc 1]]
else
[%w[Xaa 1], %w[Ybb 2], %w[Zcc 3]]
end
opts = %(--layout=#{layout} --style full --header-lines 3 --header-lines-border=inline)
verify_clicks(kind: :header, opts: opts,
input: "(printf 'Xaa\\nYbb\\nZcc\\n'; seq 5)",
clicks: clicks_hl)
end
# Inline footer inside a rounded list border.
define_method(:"test_click_footer_border_inline_#{slug}") do
opts = %(--layout=#{layout} --style full --footer $'Foo\\nBar\\nBaz' --footer-border=inline)
verify_clicks(kind: :footer, opts: opts, input: 'seq 5',
clicks: [%w[Foo 1], %w[Bar 2], %w[Baz 3]])
end
end
# An inline section requesting far more rows than the terminal can fit must not
# break the layout. The list frame must still render inside the pane with both
# corners visible and the prompt line present.
def test_inline_header_lines_oversized
tmux.send_keys %(seq 10000 | #{FZF} --style full --header-border inline --header-lines 9999), :Enter
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) }
lines = tmux.capture
# Rounded (light) and sharp (tcell) default border glyphs.
top_corners = /[╭┌]/
bottom_corners = /[╰└]/
assert(lines.any? { |l| l.match?(top_corners) }, "list frame top missing: #{lines.inspect}")
assert(lines.any? { |l| l.match?(bottom_corners) }, "list frame bottom missing: #{lines.inspect}")
assert(lines.any? { |l| l.include?('>') }, "prompt missing: #{lines.inspect}")
tmux.send_keys 'Escape'
end
# A non-inline section that consumes all available rows must still render without
# crashing when another section is inline but has no budget. The inline section's
# content is clipped to 0 but the layout proceeds.
def test_inline_footer_starved_by_non_inline_header
tmux.send_keys %(seq 10000 | #{FZF} --style full --footer-border inline --footer "$(seq 1000)" --header "$(seq 1000)"), :Enter
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) }
lines = tmux.capture
assert(lines.any? { |l| l.include?('>') }, "prompt missing: #{lines.inspect}")
tmux.send_keys 'Escape'
end
# Without a line-drawing --list-border, --header-border=inline must silently
# fall back to the `line` style (documented behavior).
def test_inline_falls_back_without_list_border
tmux.send_keys %(seq 5 | #{FZF} --list-border=none --header HEADER --header-border=inline), :Enter
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) }
lines = tmux.capture
assert(lines.any? { |l| l.include?('HEADER') }, "header missing: #{lines.inspect}")
# Neither list frame corners (rounded/sharp) nor T-junction runes appear,
# since we've fallen back to a plain line separator.
assert(lines.none? { |l| l.match?(/[╭╮╰╯┌┐└┘├┤]/) }, "unexpected frame glyphs: #{lines.inspect}")
tmux.send_keys 'Escape'
end
# Regression: when --header-border=inline falls back to `line` because the
# list border can't host an inline separator, the header-border color must
# inherit from `border`, not `list-border`. The effective shape is `line`,
# so color inheritance must match what `line` rendering would use.
def test_inline_fallback_does_not_inherit_list_border_color
# Marker attribute (bold) on list-border. If HeaderBorder wrongly inherits
# from ListBorder, the header separator characters will carry the bold
# attribute. --info=hidden and --no-separator strip other separator lines
# so the only row of `─` chars is the header separator.
tmux.send_keys %(seq 5 | #{FZF} --list-border=none --header HEADER --header-border=inline --info=hidden --no-separator --color=bg:-1,list-border:red:bold), :Enter
sep_row = nil
tmux.until do |_|
sep_row = tmux.capture_ansi.find do |row|
stripped = row.gsub(/\e\[[\d;]*m/, '').rstrip
stripped.match?(/\A─+\z/)
end
!sep_row.nil?
end
# Bold (1) or red fg (31) on the header separator means it inherited from
# list-border even though the effective shape is `line` (non-inline).
refute_match(/\e\[(?:[\d;]*;)?(?:1|31)(?:;[\d;]*)?m─/, sep_row,
"header separator inherited list-border attr: #{sep_row.inspect}")
tmux.send_keys 'Escape'
end
# Inline takes precedence over --header-first: the main header stays
# inside the list frame instead of moving below the input.
def test_inline_header_border_overrides_header_first
tmux.send_keys %(seq 5 | #{FZF} --style full --header foo --header-first --header-border inline), :Enter
tmux.until do |lines|
foo_idx = lines.index { |l| l.match?(/\A│\s+foo\s+│\z/) }
input_idx = lines.index { |l| l.match?(/\A│\s+>\s+\d+\/\d+\s+│\z/) }
foo_idx && input_idx && foo_idx < input_idx
end
end
# With both sections present, --header-first still moves the main --header
# below the input while --header-lines-border=inline keeps header-lines
# inside the list frame.
def test_inline_header_lines_with_header_first_and_main_header
tmux.send_keys %(seq 5 | #{FZF} --style full --header foo --header-lines 1 --header-first --header-lines-border inline), :Enter
tmux.until do |lines|
one_idx = lines.index { |l| l.match?(/\A│\s+1\s+│\z/) }
foo_idx = lines.index { |l| l.match?(/\A│\s+foo\s+│\z/) }
input_idx = lines.index { |l| l.match?(/\A│\s+>\s+\d+\/\d+\s+│\z/) }
one_idx && foo_idx && input_idx && one_idx < input_idx && input_idx < foo_idx
end
end
# With no main --header, --header-first previously repositioned
# header-lines. Inline now takes precedence: header-lines stays inside
# the list frame.
def test_inline_header_lines_with_header_first_no_main_header
tmux.send_keys %(seq 5 | #{FZF} --style full --header-lines 1 --header-first --header-lines-border inline), :Enter
tmux.until do |lines|
one_idx = lines.index { |l| l.match?(/\A│\s+1\s+│\z/) }
input_idx = lines.index { |l| l.match?(/\A│\s+>\s+\d+\/\d+\s+│\z/) }
one_idx && input_idx && one_idx < input_idx
end
end
# Regression: with --header-border=inline and --header-lines but no
# --header, the inline slot was sized for header-lines only. After
# change-header added a main header line, resizeIfNeeded tolerated the
# too-small slot, so the header-lines line got displaced and disappeared.
def test_inline_change_header_grows_slot
tmux.send_keys %(seq 5 | #{FZF} --style full --header-lines 1 --header-border inline --bind space:change-header:tada), :Enter
tmux.until { |lines| lines.any_include?(/\A│\s+1\s+│\z/) }
tmux.send_keys ' '
tmux.until do |lines|
lines.any_include?(/\A│\s+1\s+│\z/) && lines.any_include?(/\A│\s+tada\s+│\z/)
end
end
# Invalid inline combinations must be rejected at startup.
def test_inline_rejected_on_unsupported_options
[
['--border=inline', 'inline border is only supported'],
['--list-border=inline', 'inline border is only supported'],
['--input-border=inline', 'inline border is only supported'],
['--preview-window=border-inline --preview :', 'invalid preview window option: border-inline'],
['--header-border=inline --header-lines-border=sharp --header-lines=1',
'--header-border=inline requires --header-lines-border to be inline or unset']
].each do |args, expected|
output = `#{FZF} #{args} < /dev/null 2>&1`
refute_equal 0, $CHILD_STATUS.exitstatus, "expected non-zero exit for: #{args}"
assert_includes output, expected, "wrong error for: #{args}"
end
end
private
# Count rows whose entire width is a single `color` range.
def count_full_rows(ranges_by_row, color)
ranges_by_row.count { |r| r.length == 1 && r[0][2] == color }
end
# Wait until `tmux.bg_ranges` has at least `count` fully-`color` rows; return them.
def wait_for_full_rows(color, count)
ranges = nil
tmux.until do |_|
ranges = tmux.bg_ranges
count_full_rows(ranges, color) >= count
end
ranges
end
public
# Inline header's entire section (outer edge + content-row verticals + separator)
# carries the header-bg color; list rows below carry list-bg.
def test_inline_header_bg_color
tmux.send_keys %(seq 5 | #{FZF} --list-border --reverse --header HEADER --header-border=inline --color=bg:-1,header-border:white,list-border:white,header-bg:red,list-bg:green), :Enter
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) }
# 3 fully-red rows: top edge, header content, separator.
ranges = wait_for_full_rows('red', 3)
assert_equal_org(3, count_full_rows(ranges, 'red'))
# List rows below (>=5) are fully green.
assert_operator count_full_rows(ranges, 'green'), :>=, 5
tmux.send_keys 'Escape'
end
# Regression: when --header-lines-border=inline is the only inline section
# (no --header-border), the section must still use header-bg, not list-bg.
def test_inline_header_lines_bg_without_main_header
tmux.send_keys %(seq 5 | #{FZF} --list-border --reverse --header-lines 2 --header-lines-border=inline --color=bg:-1,header-border:white,list-border:white,header-bg:red,list-bg:green), :Enter
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) }
# Top edge + 2 content rows + separator = 4 fully-red rows.
ranges = wait_for_full_rows('red', 4)
assert_equal_org(4, count_full_rows(ranges, 'red'))
tmux.send_keys 'Escape'
end
# Inline footer's entire section carries footer-bg; list rows above carry list-bg.
def test_inline_footer_bg_color
tmux.send_keys %(seq 5 | #{FZF} --list-border --footer FOOTER --footer-border=inline --color=bg:-1,footer-border:white,list-border:white,footer-bg:blue,list-bg:green), :Enter
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) }
ranges = wait_for_full_rows('blue', 3)
assert_equal_org(3, count_full_rows(ranges, 'blue'))
tmux.send_keys 'Escape'
end
# The list-label's bg is swapped to match the adjacent inline section so it reads as
# part of the section frame rather than a list-colored island on a section-colored edge.
def test_list_label_bg_on_inline_section_edge
tmux.send_keys %(seq 5 | #{FZF} --list-border --reverse --header HEADER --header-border=inline --list-label=LL --color=bg:-1,header-border:white,list-border:white,header-bg:red,list-bg:green,list-label:yellow:bold), :Enter
tmux.until { |lines| lines.any_include?(%r{ [0-9]+/[0-9]+}) }
# The label sits on the header-owned top edge, so the entire row must be a
# single red run (no green breaks where the label cells are).
ranges = wait_for_full_rows('red', 3)
assert_operator count_full_rows(ranges, 'red'), :>=, 3
tmux.send_keys 'Escape'
end
end
+68 -1
View File
@@ -383,6 +383,30 @@ class TestPreview < TestInteractive
end
end
def test_preview_follow_wrap
tmux.send_keys "seq 1 | #{FZF} --preview 'seq 1000' --preview-window right,2,follow,wrap", :Enter
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.until do |lines|
idx = lines.rindex { it.include?('│ 10 │') }
assert_includes lines[idx + 1], '│ ↳ │'
assert_includes lines[idx + 2], '│ ↳ │'
end
end
def test_preview_follow_wrap_long_line
tmux.send_keys %(seq 1 | #{FZF} --preview "seq 2; yes yes | head -10000 | tr '\n' ' '" --preview-window follow,wrap --bind up:preview-up,down:preview-down), :Enter
tmux.until do |lines|
assert_equal 1, lines.match_count
assert lines.any_include?('3/3 │')
end
tmux.send_keys :Up
tmux.until { |lines| assert lines.any_include?('2/3 │') }
tmux.send_keys :Up
tmux.until { |lines| assert lines.any_include?('1/3 │') }
tmux.send_keys :Down
tmux.until { |lines| assert lines.any_include?('2/3 │') }
end
def test_close
tmux.send_keys "seq 100 | #{FZF} --preview 'echo foo' --bind ctrl-c:close", :Enter
tmux.until { |lines| assert_equal 100, lines.match_count }
@@ -541,7 +565,7 @@ class TestPreview < TestInteractive
tmux.send_keys "seq 10 | #{FZF} --preview-border rounded --preview-window '~5,2,+0,<100000(~0,+100,wrap,noinfo)' --preview 'seq 1000'", :Enter
tmux.until { |lines| assert_equal 10, lines.match_count }
tmux.until do |lines|
assert_equal ['╭────╮', '│ 10 │', '│ ↳ 0│', '│ 10 │', '│ ↳ 1│'], lines.take(5).map(&:strip)
assert_equal ['╭────╮', '│ 10 │', '│ ↳ │', '│ 10 │', '│ ↳ │'], lines.take(5).map(&:strip)
end
end
@@ -572,4 +596,47 @@ class TestPreview < TestInteractive
assert_equal 1, lines.match_count
end
end
def test_preview_wrap_sign_between_ansi_fragments
tmux.send_keys %(seq 1 | #{FZF} --preview 'echo -e "\\x1b[33m1234567890 \\x1b[mhello"; echo -e "\\x1b[33m1234567890 \\x1b[mhello"' --preview-window 10,wrap-word), :Enter
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_equal(2, lines.count { |line| line.include?('│ 1234567890 │') })
assert_equal(2, lines.count { |line| line.include?('│ ↳ hello │') })
end
end
def test_preview_wrap_sign_between_ansi_fragments_overflow
tmux.send_keys %(seq 1 | #{FZF} --preview 'echo -e "\\x1b[33m123 \\x1b[mhi"; echo -e "\\x1b[33m123 \\x1b[mhi"' --preview-window 2,wrap-word,noinfo), :Enter
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_equal(2, lines.count { |line| line.include?('│ 12 │') })
assert_equal(0, lines.count { |line| line.include?('│ h') })
end
end
def test_preview_wrap_sign_between_ansi_fragments_overflow2
tmux.send_keys %(seq 1 | #{FZF} --preview 'echo -e "\\x1b[33m123 \\x1b[mhi"; echo -e "\\x1b[33m123 \\x1b[mhi"' --preview-window 1,wrap-word,noinfo), :Enter
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_equal(2, lines.count { |line| line.include?('│ 1 │') })
assert_equal(0, lines.count { |line| line.include?('│ h') })
end
end
def test_preview_toggle_should_redraw_scrollbar
tmux.send_keys %(seq 1 | #{FZF} --no-border --scrollbar --preview 'seq $((FZF_PREVIEW_LINES + 1))' --preview-border line --bind tab:toggle-preview --header foo --header-border --footer bar --footer-border), :Enter
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_operator lines.count { |line| line.end_with?('│') }, :>, 2
end
tmux.send_keys :Tab
tmux.until do |lines|
assert_equal(2, lines.count { |line| line.end_with?('│') })
end
tmux.send_keys :Tab
tmux.until do |lines|
assert_operator lines.count { |line| line.end_with?('│') }, :>, 2
end
end
end

Some files were not shown because too many files have changed in this diff Show More