Compare commits

...

73 Commits

Author SHA1 Message Date
Junegunn Choi ce58d08ee3 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 11:58:16 +09:00
Junegunn Choi 997a7e5947 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 11:58:16 +09:00
Junegunn Choi 88e48619d6 Return Result by value from MatchItem 2026-03-01 11:18:43 +09:00
Junegunn Choi 2db14b4308 Enhance --bench output with formatted times, match count, and selectivity 2026-03-01 11:16:52 +09:00
junegunn 90c4269d4e Deploying to master from @ junegunn/fzf@6087055305 🚀 2026-02-28 15:01:49 +00:00
Junegunn Choi 6087055305 Enable uint64 compareRanks on arm64
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
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
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
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
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 🚀 2026-02-21 15:03:44 +00:00
Junegunn Choi b908f7a0ec 0.68.0 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)
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 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 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
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 2026-02-09 09:43:47 +09:00
Sam Killen 880dd20b18 Make symlinks to directories to return as directories, not files (#4676)
Fix #4675
2026-02-08 12:57:45 +09:00
junegunn 91aa25c863 Deploying to master from @ junegunn/fzf@7e62b34087 🚀 2026-02-08 00:06:19 +00:00
LM 7e62b34087 Add fish completion support (#4605) 2026-02-05 12:50:26 +09:00
Stephen Bolton f9f0014c16 Fix bug in advanced guide for using fzf as a ripgrep launcher (#4670)
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 2026-02-02 09:40:38 +09:00
LangLangBart 7d9724157c fix(terminal): handle SIGHUP signal (#4668) 2026-02-01 19:51:49 +09:00
Junegunn Choi bc8967632b Fix preview process not killed on exit
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
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) 2026-01-28 00:39:05 +09:00
Junegunn Choi b389616030 Fix track-current unset after a combined movement action
Fix #4649
Close #4663
2026-01-26 22:00:30 +09:00
junegunn 25b2248f11 Deploying to master from @ junegunn/fzf@14564e4fc7 🚀 2026-01-25 00:02:24 +00:00
Junegunn Choi 14564e4fc7 Avoid clearing the rest of the current line on start
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 🚀 2026-01-18 00:02:25 +00:00
Junegunn Choi aad805d0d3 Fix x-api-key header not required for GET requests
Fix #4627
2026-01-17 15:13:57 +09:00
junegunn d1f037059a Deploying to master from @ junegunn/fzf@3f94bcb5bf 🚀 2026-01-11 00:02:24 +00:00
Junegunn Choi 3f94bcb5bf Cancel key reading when 'execute' triggered via a server request (#4653)
Fix #4524
Close #4648
2026-01-09 00:29:40 +09:00
junegunn 3c7cbc9d47 Deploying to master from @ junegunn/fzf@28e2a067e5 🚀 2026-01-04 00:02:18 +00:00
James Lazo (jazo) 28e2a067e5 fix: rebind readline command redraw-current-line (#4635)
Close #4634
2026-01-03 01:44:49 +09:00
junegunn b29e2ee2d1 Deploying to master from @ junegunn/fzf@029b241dbb 🚀 2025-12-28 00:02:17 +00:00
mrclmr 029b241dbb Fix goreleaser deprecation warnings (#4644)
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 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 🚀 2025-12-21 00:02:22 +00:00
Charalambos Emmanouilidis 8d688521fe Fix --accept-nth being ignored in filter mode (#4636)
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)
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 🚀 2025-12-14 00:02:25 +00:00
LangLangBart c8cf0992c1 zsh foreign test (#4622)
* 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 🚀 2025-12-07 00:02:17 +00:00
Junegunn Choi b473477c22 Update README 2025-12-04 08:36:49 +09:00
LangLangBart fcc4178bca Updates man page documentation (#4607)
* 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) 2025-11-30 21:32:55 +09:00
Junegunn Choi af2a81dc02 Add Goods code 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
Close #4608
2025-11-28 18:41:45 +09:00
71 changed files with 4069 additions and 1307 deletions
+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
+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@v5
with:
configuration-path: .github/labeler.yml
+1 -1
View File
@@ -3,7 +3,7 @@ name: Generate Sponsors README
on:
workflow_dispatch:
schedule:
- cron: 0 0 * * 0
- cron: 0 15 * * 6
jobs:
deploy:
runs-on: ubuntu-latest
+6 -4
View File
@@ -85,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*
@@ -102,7 +104,7 @@ release:
name_template: '{{ .Version }}'
snapshot:
name_template: "{{ .Version }}-devel"
version_template: "{{ .Version }}-devel"
changelog:
sort: asc
+1 -1
View File
@@ -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}' \
+55
View File
@@ -1,6 +1,61 @@
CHANGELOG
=========
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.
+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
+5 -4
View File
@@ -1,4 +1,5 @@
GO ?= go
DOCKER ?= docker
GOOS ?= $(shell $(GO) env GOOS)
MAKEFILE := $(realpath $(lastword $(MAKEFILE_LIST)))
@@ -192,12 +193,12 @@ 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
+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
+99 -41
View File
File diff suppressed because one or more lines are too long
+7 -8
View File
@@ -159,11 +159,11 @@ 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_off_opts='; set-option -p synchronize-panes off ; set-option -p remain-on-exit off'
@@ -172,8 +172,7 @@ else
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
eval "tmux ${tmux_win_opts[*]}"
@@ -196,7 +195,7 @@ cleanup() {
trap 'cleanup 1' SIGUSR1
trap 'cleanup' EXIT
envs="export TERM=$TERM "
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"
+1 -1
View File
@@ -503,7 +503,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:
+66 -20
View File
@@ -2,7 +2,7 @@
set -u
version=0.67.0
version=0.68.0
auto_completion=
key_bindings=
update_config=2
@@ -243,16 +243,16 @@ fi
echo
for shell in $shells; do
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
@@ -302,15 +302,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
@@ -328,8 +329,12 @@ append_line() {
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"
@@ -362,25 +367,66 @@ for shell in $shells; do
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
@@ -393,7 +439,7 @@ if [ $update_config -eq 1 ]; then
echo
fi
[[ $shells =~ zsh ]] && echo " source ${ZDOTDIR:-~}/.zshrc # zsh"
[[ $shells =~ fish ]] && [ $key_bindings -eq 1 ] && echo ' fzf_key_bindings # fish'
[[ $shells =~ fish && $lno_func -ne 0 ]] && echo ' fzf_user_key_bindings # fish'
echo
echo 'Use uninstall script to remove fzf.'
echo
+1 -1
View File
@@ -1,4 +1,4 @@
$version="0.67.0"
$version="0.68.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.67"
var version = "0.68"
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 "Nov 2025" "fzf 0.67.0" "fzf\-tmux - open fzf in tmux split pane"
.TH fzf\-tmux 1 "Feb 2026" "fzf 0.68.0" "fzf\-tmux - open fzf in tmux split pane"
.SH NAME
fzf\-tmux - open fzf in tmux split pane
+34 -11
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 "Nov 2025" "fzf 0.67.0" "fzf - a command-line fuzzy finder"
.TH fzf 1 "Feb 2026" "fzf 0.68.0" "fzf - a command-line fuzzy finder"
.SH NAME
fzf - a command-line fuzzy finder
@@ -272,6 +272,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)
@@ -325,10 +326,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 \fRClear previously set attributes; should precede the other ones
\fBstrip \fRRemove colors
\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
@@ -588,8 +593,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
@@ -740,7 +748,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"
@@ -840,6 +848,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
@@ -908,6 +919,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
@@ -918,7 +934,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)
@@ -936,7 +952,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.
@@ -1373,6 +1390,8 @@ fzf exports the following environment variables to its child processes.
.br
.BR FZF_POS " Vertical position of the cursor in the list starting from 1"
.br
.BR FZF_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)"
@@ -1862,6 +1881,7 @@ A key or an event can be bound to one or more of the following actions.
\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)
@@ -1953,12 +1973,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)
@@ -1966,6 +1988,7 @@ 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)
@@ -2105,7 +2128,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 ||
+1 -1
View File
@@ -1,4 +1,4 @@
" Copyright (c) 2013-2025 Junegunn Choi
" Copyright (c) 2013-2026 Junegunn Choi
"
" MIT License
"
+141
View File
@@ -0,0 +1,141 @@
function __fzf_defaults
# $argv[1]: Prepend to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
# $argv[2..]: Append to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40%
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]
end
function __fzfcmd
test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40%
if test -n "$FZF_TMUX_OPTS"
echo "fzf-tmux $FZF_TMUX_OPTS -- "
else if test "$FZF_TMUX" = "1"
echo "fzf-tmux -d$FZF_TMUX_HEIGHT -- "
else
echo "fzf"
end
end
function __fzf_cmd_tokens -d 'Return command line tokens, skipping leading env assignments and command prefixes'
# Get tokens - use version-appropriate flags
set -l tokens
if test (string match -r -- '^\d+' $version) -ge 4
set -- tokens (commandline -xpc)
else
set -- tokens (commandline -opc)
end
# Filter out leading environment variable assignments
set -l -- var_count 0
for i in $tokens
if string match -qr -- '^[\w]+=' $i
set var_count (math $var_count + 1)
else
break
end
end
set -e -- tokens[0..$var_count]
# Skip command prefixes so callers see the actual command name,
# e.g. "builtin cd" → "cd", "env VAR=1 command cd" → "cd"
while true
switch "$tokens[1]"
case builtin command
set -e -- tokens[1]
test "$tokens[1]" = "--"; and set -e -- tokens[1]
case env
set -e -- tokens[1]
test "$tokens[1]" = "--"; and set -e -- tokens[1]
while string match -qr -- '^[\w]+=' "$tokens[1]"
set -e -- tokens[1]
end
case '*'
break
end
end
string escape -n -- $tokens
end
function __fzf_parse_commandline -d 'Parse the current command line token and return split of existing filepath, fzf query, and optional -option= prefix'
set -l fzf_query ''
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]
# 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
# Set $prefix and expanded $fzf_query with preserved trailing newlines.
if test "$fish_major" -ge 4
# 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$' '')
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 \)
# fish v3.5.0 and newer
set -- fzf_query (path normalize -- $fzf_query)
set -- dir $fzf_query
while not path is -d $dir
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
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
# Strip $dir from $fzf_query - preserve trailing newlines.
if test "$fish_major" -ge 4
# 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)
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$' '')
end
end
end
string escape -n -- "$dir" "$fzf_query" "$prefix"
end
+61 -13
View File
@@ -124,6 +124,7 @@ _fzf_opts_completion() {
+i --no-ignore-case
+s --no-sort
+x --no-extended
--accept-nth
--ansi
--bash
--bind
@@ -137,56 +138,88 @@ _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
--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
@@ -206,11 +239,11 @@ _fzf_opts_completion() {
return 0
;;
--tiebreak)
COMPREPLY=($(compgen -W "length chunk begin end index" -- "$cur"))
COMPREPLY=($(compgen -W "length chunk pathname begin end index" -- "$cur"))
return 0
;;
--color)
COMPREPLY=($(compgen -W "dark light 16 bw no" -- "$cur"))
COMPREPLY=($(compgen -W "dark light base16 16 bw no" -- "$cur"))
return 0
;;
--layout)
@@ -221,12 +254,21 @@ _fzf_opts_completion() {
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
@@ -235,6 +277,7 @@ _fzf_opts_completion() {
left
right
rounded border border-rounded
border-line
sharp border-sharp
border-bold
border-block
@@ -248,14 +291,16 @@ _fzf_opts_completion() {
border-left
border-right
follow
nofollow" -- "$cur"))
nofollow
info
noinfo" -- "$cur"))
return 0
;;
--border)
COMPREPLY=($(compgen -W "rounded sharp bold block thinblock double horizontal vertical top bottom left right none" -- "$cur"))
--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)
--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
;;
@@ -325,15 +370,18 @@ __fzf_generic_path_completion() {
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 $(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
+241
View File
@@ -0,0 +1,241 @@
# ____ ____
# / __/___ / __/
# / /_/_ / / /_
# / __/ / /_/ __/
# /_/ /___/_/ completion.fish
#
# - $FZF_COMPLETION_OPTS (default: empty)
function fzf_completion_setup
#----BEGIN INCLUDE common.fish
# NOTE: Do not directly edit this section, which is copied from "common.fish".
# To modify it, one can edit "common.fish" and run "./update.sh" to apply
# the changes. See code comments in "common.fish" for the implementation details.
function __fzf_defaults
test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40%
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]
end
function __fzfcmd
test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40%
if test -n "$FZF_TMUX_OPTS"
echo "fzf-tmux $FZF_TMUX_OPTS -- "
else if test "$FZF_TMUX" = "1"
echo "fzf-tmux -d$FZF_TMUX_HEIGHT -- "
else
echo "fzf"
end
end
function __fzf_cmd_tokens -d 'Return command line tokens, skipping leading env assignments and command prefixes'
set -l tokens
if test (string match -r -- '^\d+' $version) -ge 4
set -- tokens (commandline -xpc)
else
set -- tokens (commandline -opc)
end
set -l -- var_count 0
for i in $tokens
if string match -qr -- '^[\w]+=' $i
set var_count (math $var_count + 1)
else
break
end
end
set -e -- tokens[0..$var_count]
while true
switch "$tokens[1]"
case builtin command
set -e -- tokens[1]
test "$tokens[1]" = "--"; and set -e -- tokens[1]
case env
set -e -- tokens[1]
test "$tokens[1]" = "--"; and set -e -- tokens[1]
while string match -qr -- '^[\w]+=' "$tokens[1]"
set -e -- tokens[1]
end
case '*'
break
end
end
string escape -n -- $tokens
end
function __fzf_parse_commandline -d 'Parse the current command line token and return split of existing filepath, fzf query, and optional -option= prefix'
set -l fzf_query ''
set -l prefix ''
set -l dir '.'
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'
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
if test "$fish_major" -ge 4
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
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
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$' '')
end
if test -n "$fzf_query"
if test \( "$fish_major" -ge 4 \) -o \( "$fish_major" -eq 3 -a "$fish_minor" -ge 5 \)
set -- fzf_query (path normalize -- $fzf_query)
set -- dir $fzf_query
while not path is -d $dir
set -- dir (path dirname $dir)
end
else
if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
string match -q -r -- '(?<fzf_query>^[\s\S]*?(?=\n?$)$)' \
(string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $fzf_query | string collect -N)
else
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
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 test "$fish_major" -ge 4
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
string match -q -r -- '^/?(?<fzf_query>[\s\S]*?(?=\n?$)$)' \
(string replace -- "$dir" '' $fzf_query | string collect -N)
else
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$' '')
end
end
end
string escape -n -- "$dir" "$fzf_query" "$prefix"
end
#----END INCLUDE
# Use complete builtin for specific commands
function __fzf_complete_native
set -l -- token (commandline -t)
set -l -- completions (eval complete -C \"$argv[1]\")
test -n "$completions"; or begin commandline -f repaint; return; end
# Calculate tabstop based on longest completion item (sample first 500 for performance)
set -l -- tabstop 20
set -l -- sample_size (math "min(500, "(count $completions)")")
for c in $completions[1..$sample_size]
set -l -- len (string length -V -- (string split -- \t $c))
test -n "$len[2]" -a "$len[1]" -gt "$tabstop"
and set -- tabstop $len[1]
end
# limit to 120 to prevent long lines
set -- tabstop (math "min($tabstop + 4, 120)")
set -l result
set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults \
"--reverse --delimiter=\\t --nth=1 --tabstop=$tabstop --color=fg:dim,nth:regular" \
$FZF_COMPLETION_OPTS $argv[2..-1] --accept-nth=1 --read0 --print0)
set -- result (string join0 -- $completions | eval (__fzfcmd) | string split0)
and begin
set -l -- tail ' '
# Append / to bare ~username results (fish omits it unlike other shells)
set -- result (string replace -r -- '^(~\w+)\s?$' '$1/' $result)
# Don't add trailing space if single result is a directory
test (count $result) -eq 1
and string match -q -- '*/' "$result"; and set -- tail ''
set -l -- result (string escape -n -- $result)
string match -q -- '~*' "$token"
and set result (string replace -r -- '^\\\\~' '~' $result)
string match -q -- '$*' "$token"
and set result (string replace -r -- '^\\\\\$' '\$' $result)
commandline -rt -- (string join ' ' -- $result)$tail
end
commandline -f repaint
end
function _fzf_complete
set -l -- args (string escape -- $argv | string join ' ' | string split -- ' -- ')
set -l -- post_func (status function)_(string split -- ' ' $args[2])[1]_post
set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults --reverse $FZF_COMPLETION_OPTS $args[1])
set -lx FZF_DEFAULT_OPTS_FILE
set -lx FZF_DEFAULT_COMMAND
set -l -- fzf_query (commandline -t | string escape)
set -l result
eval (__fzfcmd) --query=$fzf_query | while read -l r; set -a -- result $r; end
and if functions -q $post_func
commandline -rt -- (string collect -- $result | eval $post_func $args[2] | string join ' ')' '
else
commandline -rt -- (string join -- ' ' (string escape -- $result))' '
end
commandline -f repaint
end
# Kill completion (process selection)
function _fzf_complete_kill
set -l -- fzf_query (commandline -t | string escape)
set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults --reverse $FZF_COMPLETION_OPTS \
--accept-nth=2 -m --header-lines=1 --no-preview --wrap)
set -lx FZF_DEFAULT_OPTS_FILE
if type -q ps
set -l -- ps_cmd 'begin command ps -eo user,pid,ppid,start,time,command 2>/dev/null;' \
'or command ps -eo user,pid,ppid,time,args 2>/dev/null;' \
'or command ps --everyone --full --windows 2>/dev/null; end'
set -l -- result (eval $ps_cmd \| (__fzfcmd) --query=$fzf_query)
and commandline -rt -- (string join ' ' -- $result)" "
else
__fzf_complete_native "kill " --multi --query=$fzf_query
end
commandline -f repaint
end
# Main completion function
function fzf-completion
set -l -- tokens (__fzf_cmd_tokens)
set -l -- current_token (commandline -t)
set -l -- cmd_name $tokens[1]
# Route to appropriate completion function
if test -n "$tokens"; and functions -q _fzf_complete_$cmd_name
_fzf_complete_$cmd_name $tokens
else
set -l -- fzf_opt --query=$current_token --multi
__fzf_complete_native "$tokens $current_token" $fzf_opt
end
end
# Bind Shift-Tab to fzf-completion (Tab retains native Fish behavior)
if test (string match -r -- '^\d+' $version) -ge 4
bind shift-tab fzf-completion
bind -M insert shift-tab fzf-completion
else
bind -k btab fzf-completion
bind -M insert -k btab fzf-completion
end
end
# Run setup
fzf_completion_setup
+6 -3
View File
@@ -174,15 +174,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
+4 -4
View File
@@ -121,7 +121,7 @@ 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'
@@ -130,7 +130,7 @@ bind -m emacs-standard '"\C-z": vi-editing-mode'
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-_"'
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
@@ -140,7 +140,7 @@ if ((BASH_VERSINFO[0] < 4)); 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\er"'
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
@@ -165,7 +165,7 @@ 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-_"'
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
+88 -38
View File
@@ -21,8 +21,8 @@
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 \)
if set -l -- fish_ver (string match -r '^(\d+)\.(\d+)' $version 2>/dev/null)
and 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
return 1
else if not type -q fzf
@@ -30,9 +30,12 @@ function fzf_key_bindings
return 1
end
#----BEGIN INCLUDE common.fish
# NOTE: Do not directly edit this section, which is copied from "common.fish".
# To modify it, one can edit "common.fish" and run "./update.sh" to apply
# the changes. See code comments in "common.fish" for the implementation details.
function __fzf_defaults
# $argv[1]: Prepend to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
# $argv[2..]: Append to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
test -n "$FZF_TMUX_HEIGHT"; or set -l FZF_TMUX_HEIGHT 40%
string join ' ' -- \
"--height $FZF_TMUX_HEIGHT --min-height=20+ --bind=ctrl-z:ignore" $argv[1] \
@@ -51,17 +54,51 @@ function fzf_key_bindings
end
end
function __fzf_cmd_tokens -d 'Return command line tokens, skipping leading env assignments and command prefixes'
set -l tokens
if test (string match -r -- '^\d+' $version) -ge 4
set -- tokens (commandline -xpc)
else
set -- tokens (commandline -opc)
end
set -l -- var_count 0
for i in $tokens
if string match -qr -- '^[\w]+=' $i
set var_count (math $var_count + 1)
else
break
end
end
set -e -- tokens[0..$var_count]
while true
switch "$tokens[1]"
case builtin command
set -e -- tokens[1]
test "$tokens[1]" = "--"; and set -e -- tokens[1]
case env
set -e -- tokens[1]
test "$tokens[1]" = "--"; and set -e -- tokens[1]
while string match -qr -- '^[\w]+=' "$tokens[1]"
set -e -- tokens[1]
end
case '*'
break
end
end
string escape -n -- $tokens
end
function __fzf_parse_commandline -d 'Parse the current command line token and return split of existing filepath, fzf query, and optional -option= prefix'
set -l fzf_query ''
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]
# 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
@@ -69,16 +106,12 @@ function fzf_key_bindings
set -- match_regex "(?<prefix>$prefix_regex)?$match_regex"
end
# Set $prefix and expanded $fzf_query with preserved trailing newlines.
if test "$fish_major" -ge 4
# 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)
@@ -86,22 +119,17 @@ function fzf_key_bindings
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 \)
# fish v3.5.0 and newer
set -- fzf_query (path normalize -- $fzf_query)
set -- dir $fzf_query
while not path is -d $dir
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
@@ -112,16 +140,12 @@ function fzf_key_bindings
end
if not string match -q -- '.' $dir; or string match -q -r -- '^\./|^\.$' $fzf_query
# Strip $dir from $fzf_query - preserve trailing newlines.
if test "$fish_major" -ge 4
# 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)
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$' '')
end
@@ -130,6 +154,7 @@ function fzf_key_bindings
string escape -n -- "$dir" "$fzf_query" "$prefix"
end
#----END INCLUDE
# Store current token in $dir as root for the 'find' command
function fzf-file-widget -d "List files and folders"
@@ -140,13 +165,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 --no-quoted -- $result))' '
commandline -f repaint
end
@@ -157,24 +182,46 @@ 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)\'' \
set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults '' \
'--nth=2..,.. --scheme=history --multi --no-multi-line --no-wrap --wrap-sign="\t\t\t↳ " --preview-wrap-sign="↳ "' \
'--bind=\'shift-delete:execute-silent(for i in (string split0 -- <{+f}); eval builtin history delete --exact --case-sensitive -- (string escape -n -- $i | string replace -r "^\d*\\\\\\t" ""); end)+reload(eval $FZF_DEFAULT_COMMAND)\'' \
'--bind="alt-enter:become(string join0 -- (string collect -- {+2..} | fish_indent -i))"' \
"--bind=ctrl-r:toggle-sort,alt-r:toggle-raw --highlight-line $FZF_CTRL_R_OPTS" \
'--accept-nth=2.. --read0 --print0 --with-shell='(status fish-path)\\ -c)
'--accept-nth=2.. --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"
# Convert the highlighted timestamp using the date command if available
set -l -- date_cmd '{1}'
if type -q date
if date -d @0 '+%s' 2>/dev/null | string match -q 0
# GNU date
set -- date_cmd '(date -d @{1} \\"+%F %a %T\\")'
else if date -r 0 '+%s' 2>/dev/null | string match -q 0
# BSD date
set -- date_cmd '(date -r {1} \\"+%F %a %T\\")'
end
end
# Prepend the options to allow user customizations
set -p -- FZF_DEFAULT_OPTS \
'--bind="focus,resize:bg-transform:if test \\"$FZF_COLUMNS\\" -gt 100 -a \\\\( \\"$FZF_SELECT_COUNT\\" -gt 0 -o \\\\( -z \\"$FZF_WRAP\\" -a (string length -- {}) -gt (math $FZF_COLUMNS - 4) \\\\) -o (string collect -- {2..} | fish_indent | count) -gt 1 \\\\); echo show-preview; else echo hide-preview; end"' \
'--preview="string collect -- (test \\"$FZF_SELECT_COUNT\\" -gt 0; and string collect -- {+2..}) \\"\\n# \\"'$date_cmd' {2..} | 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\''
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 -lx -- FZF_DEFAULT_COMMAND 'builtin history -z --show-time="%s%t"'
# Enable syntax highlighting colors on fish v4.3.3 and newer
if set -l -- v (string match -r -- '^(\d+)\.(\d+)(?:\.(\d+))?' $version)
and test "$v[2]" -gt 4 -o "$v[2]" -eq 4 -a \
\( "$v[3]" -gt 3 -o "$v[3]" -eq 3 -a \
\( -n "$v[4]" -a "$v[4]" -ge 3 \) \)
set -a -- FZF_DEFAULT_OPTS '--ansi'
set -a -- FZF_DEFAULT_COMMAND '--color=always'
end
# Merge history from other sessions before searching
@@ -182,11 +229,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
@@ -234,3 +281,6 @@ function fzf_key_bindings
end
end
# Run setup
fzf_key_bindings
+33 -6
View File
@@ -128,25 +128,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_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,alt-r:toggle-raw --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,alt-r:toggle-raw --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
+14 -12
View File
@@ -8,24 +8,26 @@ dir=${0%"${0##*/}"}
update() {
{
sed -n '1,/^#----BEGIN INCLUDE common\.sh/p' "$1"
sed -n "1,/^#----BEGIN INCLUDE $1/p" "$2"
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.
# NOTE: Do not directly edit this section, which is copied from "$1".
# To modify it, one can edit "$1" and run "./update.sh" to apply
# the changes. See code comments in "$1" 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"
grep -v '^[[:blank:]]*#' "$dir/$1" # remove code comments from the common file
sed -n '/^#----END INCLUDE/,$p' "$2"
} > "$2.part"
mv -f "$1.part" "$1"
mv -f "$2.part" "$2"
}
update "$dir/completion.bash"
update "$dir/completion.zsh"
update "$dir/key-bindings.bash"
update "$dir/key-bindings.zsh"
update "common.sh" "$dir/completion.bash"
update "common.sh" "$dir/completion.zsh"
update "common.sh" "$dir/key-bindings.bash"
update "common.sh" "$dir/key-bindings.zsh"
update "common.fish" "$dir/completion.fish"
update "common.fish" "$dir/key-bindings.fish"
# Check if --check is in ARGV
check=0
+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
+155 -150
View File
@@ -30,159 +30,164 @@ 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[actToggleRaw-69]
_ = x[actEnableRaw-70]
_ = x[actDisableRaw-71]
_ = x[actTrackCurrent-72]
_ = x[actToggleInput-73]
_ = x[actHideInput-74]
_ = x[actShowInput-75]
_ = x[actUntrackCurrent-76]
_ = x[actDown-77]
_ = x[actDownMatch-78]
_ = x[actUp-79]
_ = x[actUpMatch-80]
_ = x[actPageUp-81]
_ = x[actPageDown-82]
_ = x[actPosition-83]
_ = x[actHalfPageUp-84]
_ = x[actHalfPageDown-85]
_ = x[actOffsetUp-86]
_ = x[actOffsetDown-87]
_ = x[actOffsetMiddle-88]
_ = x[actJump-89]
_ = x[actJumpAccept-90]
_ = x[actPrintQuery-91]
_ = x[actRefreshPreview-92]
_ = x[actReplaceQuery-93]
_ = x[actToggleSort-94]
_ = x[actShowPreview-95]
_ = x[actHidePreview-96]
_ = x[actTogglePreview-97]
_ = x[actTogglePreviewWrap-98]
_ = x[actTransform-99]
_ = x[actTransformBorderLabel-100]
_ = x[actTransformGhost-101]
_ = x[actTransformHeader-102]
_ = x[actTransformFooter-103]
_ = x[actTransformHeaderLabel-104]
_ = x[actTransformFooterLabel-105]
_ = x[actTransformInputLabel-106]
_ = x[actTransformListLabel-107]
_ = x[actTransformNth-108]
_ = x[actTransformPointer-109]
_ = x[actTransformPreviewLabel-110]
_ = x[actTransformPrompt-111]
_ = x[actTransformQuery-112]
_ = x[actTransformSearch-113]
_ = x[actTrigger-114]
_ = x[actBgTransform-115]
_ = x[actBgTransformBorderLabel-116]
_ = x[actBgTransformGhost-117]
_ = x[actBgTransformHeader-118]
_ = x[actBgTransformFooter-119]
_ = x[actBgTransformHeaderLabel-120]
_ = x[actBgTransformFooterLabel-121]
_ = x[actBgTransformInputLabel-122]
_ = x[actBgTransformListLabel-123]
_ = x[actBgTransformNth-124]
_ = x[actBgTransformPointer-125]
_ = x[actBgTransformPreviewLabel-126]
_ = x[actBgTransformPrompt-127]
_ = x[actBgTransformQuery-128]
_ = x[actBgTransformSearch-129]
_ = x[actBgCancel-130]
_ = x[actSearch-131]
_ = x[actPreview-132]
_ = x[actPreviewTop-133]
_ = x[actPreviewBottom-134]
_ = x[actPreviewUp-135]
_ = x[actPreviewDown-136]
_ = x[actPreviewPageUp-137]
_ = x[actPreviewPageDown-138]
_ = x[actPreviewHalfPageUp-139]
_ = x[actPreviewHalfPageDown-140]
_ = x[actPrevHistory-141]
_ = x[actPrevSelected-142]
_ = x[actPrint-143]
_ = x[actPut-144]
_ = x[actNextHistory-145]
_ = x[actNextSelected-146]
_ = x[actExecute-147]
_ = x[actExecuteSilent-148]
_ = x[actExecuteMulti-149]
_ = x[actSigStop-150]
_ = x[actBest-151]
_ = x[actFirst-152]
_ = x[actLast-153]
_ = x[actReload-154]
_ = x[actReloadSync-155]
_ = x[actDisableSearch-156]
_ = x[actEnableSearch-157]
_ = x[actSelect-158]
_ = x[actDeselect-159]
_ = x[actUnbind-160]
_ = x[actRebind-161]
_ = x[actToggleBind-162]
_ = x[actBecome-163]
_ = x[actShowHeader-164]
_ = x[actHideHeader-165]
_ = x[actBell-166]
_ = x[actExclude-167]
_ = x[actExcludeMulti-168]
_ = x[actAsync-169]
_ = 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[actChangePointer-30]
_ = x[actChangePreview-31]
_ = x[actChangePreviewLabel-32]
_ = x[actChangePreviewWindow-33]
_ = x[actChangePrompt-34]
_ = x[actChangeQuery-35]
_ = x[actClearScreen-36]
_ = x[actClearQuery-37]
_ = x[actClearSelection-38]
_ = x[actClose-39]
_ = x[actDeleteChar-40]
_ = x[actDeleteCharEof-41]
_ = x[actEndOfLine-42]
_ = x[actFatal-43]
_ = x[actForwardChar-44]
_ = x[actForwardWord-45]
_ = x[actForwardSubWord-46]
_ = x[actKillLine-47]
_ = x[actKillWord-48]
_ = x[actKillSubWord-49]
_ = x[actUnixLineDiscard-50]
_ = x[actUnixWordRubout-51]
_ = x[actYank-52]
_ = x[actBackwardKillWord-53]
_ = x[actBackwardKillSubWord-54]
_ = x[actSelectAll-55]
_ = x[actDeselectAll-56]
_ = x[actToggle-57]
_ = x[actToggleSearch-58]
_ = x[actToggleAll-59]
_ = x[actToggleDown-60]
_ = x[actToggleUp-61]
_ = x[actToggleIn-62]
_ = x[actToggleOut-63]
_ = x[actToggleTrack-64]
_ = x[actToggleTrackCurrent-65]
_ = x[actToggleHeader-66]
_ = x[actToggleWrap-67]
_ = x[actToggleWrapWord-68]
_ = x[actToggleMultiLine-69]
_ = x[actToggleHscroll-70]
_ = x[actToggleRaw-71]
_ = x[actEnableRaw-72]
_ = x[actDisableRaw-73]
_ = x[actTrackCurrent-74]
_ = x[actToggleInput-75]
_ = x[actHideInput-76]
_ = x[actShowInput-77]
_ = x[actUntrackCurrent-78]
_ = x[actDown-79]
_ = x[actDownMatch-80]
_ = x[actUp-81]
_ = x[actUpMatch-82]
_ = x[actPageUp-83]
_ = x[actPageDown-84]
_ = x[actPosition-85]
_ = x[actHalfPageUp-86]
_ = x[actHalfPageDown-87]
_ = x[actOffsetUp-88]
_ = x[actOffsetDown-89]
_ = x[actOffsetMiddle-90]
_ = x[actJump-91]
_ = x[actJumpAccept-92]
_ = x[actPrintQuery-93]
_ = x[actRefreshPreview-94]
_ = x[actReplaceQuery-95]
_ = x[actToggleSort-96]
_ = x[actShowPreview-97]
_ = x[actHidePreview-98]
_ = x[actTogglePreview-99]
_ = x[actTogglePreviewWrap-100]
_ = x[actTogglePreviewWrapWord-101]
_ = x[actTransform-102]
_ = x[actTransformBorderLabel-103]
_ = x[actTransformGhost-104]
_ = x[actTransformHeader-105]
_ = x[actTransformHeaderLines-106]
_ = x[actTransformFooter-107]
_ = x[actTransformHeaderLabel-108]
_ = x[actTransformFooterLabel-109]
_ = x[actTransformInputLabel-110]
_ = x[actTransformListLabel-111]
_ = x[actTransformNth-112]
_ = x[actTransformPointer-113]
_ = x[actTransformPreviewLabel-114]
_ = x[actTransformPrompt-115]
_ = x[actTransformQuery-116]
_ = x[actTransformSearch-117]
_ = x[actTrigger-118]
_ = x[actBgTransform-119]
_ = x[actBgTransformBorderLabel-120]
_ = x[actBgTransformGhost-121]
_ = x[actBgTransformHeader-122]
_ = x[actBgTransformHeaderLines-123]
_ = x[actBgTransformFooter-124]
_ = x[actBgTransformHeaderLabel-125]
_ = x[actBgTransformFooterLabel-126]
_ = x[actBgTransformInputLabel-127]
_ = x[actBgTransformListLabel-128]
_ = x[actBgTransformNth-129]
_ = x[actBgTransformPointer-130]
_ = x[actBgTransformPreviewLabel-131]
_ = x[actBgTransformPrompt-132]
_ = x[actBgTransformQuery-133]
_ = x[actBgTransformSearch-134]
_ = x[actBgCancel-135]
_ = x[actSearch-136]
_ = x[actPreview-137]
_ = x[actPreviewTop-138]
_ = x[actPreviewBottom-139]
_ = x[actPreviewUp-140]
_ = x[actPreviewDown-141]
_ = x[actPreviewPageUp-142]
_ = x[actPreviewPageDown-143]
_ = x[actPreviewHalfPageUp-144]
_ = x[actPreviewHalfPageDown-145]
_ = x[actPrevHistory-146]
_ = x[actPrevSelected-147]
_ = x[actPrint-148]
_ = x[actPut-149]
_ = x[actNextHistory-150]
_ = x[actNextSelected-151]
_ = x[actExecute-152]
_ = x[actExecuteSilent-153]
_ = x[actExecuteMulti-154]
_ = x[actSigStop-155]
_ = x[actBest-156]
_ = x[actFirst-157]
_ = x[actLast-158]
_ = x[actReload-159]
_ = x[actReloadSync-160]
_ = x[actDisableSearch-161]
_ = x[actEnableSearch-162]
_ = x[actSelect-163]
_ = x[actDeselect-164]
_ = x[actUnbind-165]
_ = x[actRebind-166]
_ = x[actToggleBind-167]
_ = x[actBecome-168]
_ = x[actShowHeader-169]
_ = x[actHideHeader-170]
_ = x[actBell-171]
_ = x[actExclude-172]
_ = x[actExcludeMulti-173]
_ = x[actAsync-174]
}
const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsync"
const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeHeaderLinesactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleWrapWordactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTogglePreviewWrapWordactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformHeaderLinesactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformHeaderLinesactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsync"
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, 1021, 1033, 1046, 1061, 1075, 1087, 1099, 1116, 1123, 1135, 1140, 1150, 1159, 1170, 1181, 1194, 1209, 1220, 1233, 1248, 1255, 1268, 1281, 1298, 1313, 1326, 1340, 1354, 1370, 1390, 1402, 1425, 1442, 1460, 1478, 1501, 1524, 1546, 1567, 1582, 1601, 1625, 1643, 1660, 1678, 1688, 1702, 1727, 1746, 1766, 1786, 1811, 1836, 1860, 1883, 1900, 1921, 1947, 1967, 1986, 2006, 2017, 2026, 2036, 2049, 2065, 2077, 2091, 2107, 2125, 2145, 2167, 2181, 2196, 2204, 2210, 2224, 2239, 2249, 2265, 2280, 2290, 2297, 2305, 2312, 2321, 2334, 2350, 2365, 2374, 2385, 2394, 2403, 2416, 2425, 2438, 2451, 2458, 2468, 2483, 2491}
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, 507, 529, 544, 558, 572, 585, 602, 610, 623, 639, 651, 659, 673, 687, 704, 715, 726, 740, 758, 775, 782, 801, 823, 835, 849, 858, 873, 885, 898, 909, 920, 932, 946, 967, 982, 995, 1012, 1030, 1046, 1058, 1070, 1083, 1098, 1112, 1124, 1136, 1153, 1160, 1172, 1177, 1187, 1196, 1207, 1218, 1231, 1246, 1257, 1270, 1285, 1292, 1305, 1318, 1335, 1350, 1363, 1377, 1391, 1407, 1427, 1451, 1463, 1486, 1503, 1521, 1544, 1562, 1585, 1608, 1630, 1651, 1666, 1685, 1709, 1727, 1744, 1762, 1772, 1786, 1811, 1830, 1850, 1875, 1895, 1920, 1945, 1969, 1992, 2009, 2030, 2056, 2076, 2095, 2115, 2126, 2135, 2145, 2158, 2174, 2186, 2200, 2216, 2234, 2254, 2276, 2290, 2305, 2313, 2319, 2333, 2348, 2358, 2374, 2389, 2399, 2406, 2414, 2421, 2430, 2443, 2459, 2474, 2483, 2494, 2503, 2512, 2525, 2534, 2547, 2560, 2567, 2577, 2592, 2600}
func (i actionType) String() string {
if i < 0 || i >= actionType(len(_actionType_index)-1) {
+9 -7
View File
@@ -445,7 +445,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 +503,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 +521,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 +589,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 +602,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 +686,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)
+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,
+81 -17
View File
@@ -22,20 +22,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 +55,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 +78,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 +89,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 := ""
@@ -338,15 +367,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 +391,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 +406,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 +438,7 @@ func interpretCode(ansiCode string, prevState *ansiState) ansiState {
reset := func() {
state.fg = -1
state.bg = -1
state.ul = -1
state.attr = 0
}
@@ -420,7 +454,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 +466,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 +482,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 +519,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:
+123 -17
View File
@@ -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/" +
+14
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 {
+1 -2
View File
@@ -39,7 +39,7 @@ const (
progressMinDuration = 200 * time.Millisecond
// Capacity of each chunk
chunkSize int = 100
chunkSize int = 1000
// Pre-allocated memory slices to minimize GC
slab16Size int = 100 * 1024 // 200KB * 32 = 12.8MB
@@ -65,7 +65,6 @@ const (
EvtSearchNew
EvtSearchProgress
EvtSearchFin
EvtHeader
EvtReady
EvtQuit
)
+93 -38
View File
@@ -2,6 +2,7 @@
package fzf
import (
"fmt"
"maps"
"os"
"sync"
@@ -17,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 {
@@ -38,6 +38,18 @@ 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 {
@@ -101,14 +113,8 @@ func Run(opts *Options) (int, error) {
cache := NewChunkCache()
var chunkList *ChunkList
var itemIndex int32
header := make([]string, 0, opts.HeaderLines)
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++
@@ -135,11 +141,6 @@ func Run(opts *Options) (int, error) {
}
}
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
@@ -147,7 +148,7 @@ func Run(opts *Options) (int, error) {
if item.colors != nil {
for _, ansi := range *item.colors {
if ansi.color.bg >= 0 {
maxColorOffset = util.Max32(maxColorOffset, ansi.offset[1])
maxColorOffset = max(maxColorOffset, ansi.offset[1])
}
}
}
@@ -181,7 +182,7 @@ 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
if !streamingFilter {
reader = NewReader(func(data []byte) bool {
@@ -224,15 +225,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 := 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 {
@@ -243,6 +246,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)
@@ -251,9 +256,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()
@@ -267,11 +275,51 @@ func Run(opts *Options) (int, error) {
// NOTE: Streaming filter is inherently not compatible with --tail
snapshot, _, _ := chunkList.Snapshot(opts.Tail)
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%%)\n",
len(times),
float64(avg.Microseconds())/1000,
float64(minD.Microseconds())/1000,
float64(maxD.Microseconds())/1000,
total.Seconds(),
totalItems, matchCount, selectivity)
return ExitOk, nil
}
result := matcher.scan(MatchRequest{
chunks: snapshot,
pattern: pattern})
for i := 0; i < result.merger.Length(); i++ {
opts.Printer(result.merger.Get(i).item.AsString(opts.Ansi))
opts.Printer(transformer(result.merger.Get(i).item))
found = true
}
}
@@ -316,10 +364,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
@@ -335,11 +384,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
@@ -397,7 +446,11 @@ 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)
}
@@ -407,6 +460,7 @@ func Run(opts *Options) (int, error) {
var command *commandSpec
var environ []string
var changed bool
headerLinesChanged := false
switch val := value.(type) {
case searchRequest:
sort = val.sort
@@ -427,6 +481,12 @@ 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 bump {
patternCache = make(map[string]*Pattern)
cache.Clear()
@@ -463,6 +523,14 @@ 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)
}
}
matcher.Reset(snapshot, input(), true, !reading, sort, snapshotRevision)
delay = false
@@ -472,11 +540,6 @@ 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 MatchResult:
@@ -493,15 +556,7 @@ 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)
}
}
transformer := buildItemTransformer(opts)
for i := range count {
opts.Printer(transformer(merger.Get(i).item))
}
@@ -524,7 +579,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)
+9 -9
View File
@@ -3,7 +3,6 @@ package fzf
import (
"fmt"
"runtime"
"sort"
"sync"
"time"
@@ -43,6 +42,7 @@ type Matcher struct {
reqBox *util.EventBox
partitions int
slab []*util.Slab
sortBuf [][]Result
mergerCache map[string]MatchResult
revision revision
}
@@ -54,8 +54,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 := min(numPartitionsMultiplier*runtime.NumCPU(), maxPartitions)
if threads > 0 {
partitions = threads
}
return &Matcher{
cache: cache,
patternBuilder: patternBuilder,
@@ -65,6 +68,7 @@ func NewMatcher(cache *ChunkCache, patternBuilder func([]rune) *Pattern,
reqBox: util.NewEventBox(),
partitions: partitions,
slab: make([]*util.Slab, partitions),
sortBuf: make([][]Result, partitions),
mergerCache: make(map[string]MatchResult),
revision: revision}
}
@@ -174,7 +178,7 @@ func (m *Matcher) scan(request MatchRequest) MatchResult {
return MatchResult{m, m, false}
}
pattern := request.pattern
passMerger := PassMerger(&request.chunks, m.tac, request.revision)
passMerger := PassMerger(&request.chunks, m.tac, request.revision, pattern.startIndex)
if pattern.IsEmpty() {
return MatchResult{passMerger, passMerger, false}
}
@@ -212,11 +216,7 @@ func (m *Matcher) scan(request MatchRequest) MatchResult {
sliceMatches = append(sliceMatches, matches...)
}
if m.sort && request.pattern.sortable {
if m.tac {
sort.Sort(ByRelevanceTac(sliceMatches))
} else {
sort.Sort(ByRelevance(sliceMatches))
}
m.sortBuf[idx] = radixSortResults(sliceMatches, m.tac, m.sortBuf[idx])
}
resultChan <- partialResult{idx, sliceMatches}
}(idx, m.slab[idx], chunks)
+28 -23
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
+111 -11
View File
@@ -8,6 +8,7 @@ import (
"regexp"
"strconv"
"strings"
"time"
"unicode"
"github.com/junegunn/fzf/src/algo"
@@ -95,7 +96,7 @@ 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)
@@ -157,7 +158,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)]
@@ -167,6 +168,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
@@ -292,14 +294,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 (
@@ -349,6 +369,7 @@ type previewOpts struct {
scroll string
hidden bool
wrap bool
wrapWord bool
cycle bool
follow bool
info bool
@@ -525,7 +546,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
}
@@ -587,7 +608,9 @@ type Options struct {
Layout layoutType
Cycle bool
Wrap bool
WrapWord bool
WrapSign *string
PreviewWrapSign *string
MultiLine bool
CursorLine bool
KeepRight bool
@@ -655,6 +678,8 @@ type Options struct {
WalkerSkip []string
Version bool
Help bool
Threads int
Bench time.Duration
CPUProfile string
MEMProfile string
BlockProfile string
@@ -673,7 +698,13 @@ 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 {
@@ -715,6 +746,7 @@ func defaultOptions() *Options {
Layout: layoutDefault,
Cycle: false,
Wrap: false,
WrapWord: false,
MultiLine: true,
KeepRight: false,
Hscroll: true,
@@ -1389,6 +1421,14 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, *tui
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":
@@ -1476,6 +1516,8 @@ func parseTheme(defaultTheme *tui.ColorTheme, str string) (*tui.ColorTheme, *tui
mergeAttr(&theme.Nomatch)
case "gutter":
mergeAttr(&theme.Gutter)
case "alt-gutter":
mergeAttr(&theme.AltGutter)
case "hl":
mergeAttr(&theme.Match)
case "current-hl", "hl+":
@@ -1587,7 +1629,7 @@ 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)`)
`(?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|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-]+")
}
@@ -1769,6 +1811,8 @@ 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":
@@ -1835,6 +1879,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":
@@ -1994,6 +2040,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":
@@ -2054,6 +2102,8 @@ 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":
@@ -2084,6 +2134,8 @@ 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":
@@ -2246,8 +2298,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":
@@ -2811,9 +2868,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 {
@@ -3047,6 +3124,12 @@ 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[%]")
if err != nil {
@@ -3293,6 +3376,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
+58
View File
@@ -448,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) {
+79 -21
View File
@@ -64,6 +64,9 @@ type Pattern struct {
procFun map[termType]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,
startIndex: startIndex,
procFun: make(map[termType]algo.Algo)}
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
@@ -301,18 +322,56 @@ func (p *Pattern) Match(chunk *Chunk, slab *util.Slab) []Result {
func (p *Pattern) matchChunk(chunk *Chunk, space []Result, slab *util.Slab) []Result {
matches := []Result{}
if len(p.denylist) == 0 {
// Huge code duplication for minimizing unnecessary map lookups
// 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
}
}
// 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
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++ {
res, _ := p.directAlgo(t.caseSensitive, t.normalize, p.forward,
&chunk.items[idx].text, t.text, p.withPos, slab)
if res.Start >= 0 {
matches = append(matches, buildResultFromBounds(
&chunk.items[idx], res.Score,
int(res.Start), int(res.End), int(res.End), true))
}
}
} else {
for _, result := range space {
if match, _, _ := p.MatchItem(result.item, p.withPos, slab); match != nil {
matches = append(matches, *match)
res, _ := p.directAlgo(t.caseSensitive, t.normalize, p.forward,
&result.item.text, t.text, p.withPos, slab)
if res.Start >= 0 {
matches = append(matches, buildResultFromBounds(
result.item, res.Score,
int(res.Start), int(res.End), int(res.End), true))
}
}
}
return matches
}
if len(p.denylist) == 0 {
// Huge code duplication for minimizing unnecessary map lookups
if space == nil {
for idx := startIdx; idx < chunk.count; idx++ {
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match.item != nil {
matches = append(matches, match)
}
}
} else {
for _, result := range space {
if match, _, _ := p.MatchItem(result.item, p.withPos, slab); match.item != nil {
matches = append(matches, match)
}
}
}
@@ -320,12 +379,12 @@ func (p *Pattern) matchChunk(chunk *Chunk, space []Result, slab *util.Slab) []Re
}
if space == nil {
for idx := 0; idx < chunk.count; idx++ {
for idx := startIdx; 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)
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match.item != nil {
matches = append(matches, match)
}
}
} else {
@@ -333,30 +392,29 @@ func (p *Pattern) matchChunk(chunk *Chunk, space []Result, slab *util.Slab) []Re
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 match, _, _ := p.MatchItem(result.item, p.withPos, slab); match.item != nil {
matches = append(matches, match)
}
}
}
return matches
}
// 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) {
+1 -1
View File
@@ -68,7 +68,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) {
+7 -3
View File
@@ -178,7 +178,7 @@ func (r *Reader) feed(src io.Reader) {
var err error
for {
n := 0
scope := slab[:util.Min(len(slab), readerBufferSize)]
scope := slab[:min(len(slab), readerBufferSize)]
for range 100 {
n, err = src.Read(scope)
if n > 0 || err != nil {
@@ -303,8 +303,12 @@ 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
}
isDir := de.IsDir() || isDirSymlink
if isDir {
base := filepath.Base(path)
if !opts.hidden && base[0] == '.' && base != ".." {
return filepath.SkipDir
+88 -7
View File
@@ -33,8 +33,6 @@ func buildResult(item *Item, offsets []Offset, score int) Result {
sort.Sort(ByOrder(offsets))
}
result := Result{item: item}
numChars := item.text.Length()
minBegin := math.MaxUint16
minEnd := math.MaxUint16
maxEnd := 0
@@ -42,13 +40,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 +81,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-- {
@@ -206,7 +211,7 @@ 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)
}
var colors []colorOffset
add := func(idx int) {
@@ -334,3 +339,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
}
+62 -4
View File
@@ -2,6 +2,7 @@ package fzf
import (
"math"
"math/rand"
"sort"
"testing"
@@ -124,10 +125,10 @@ 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)
@@ -182,3 +183,60 @@ func TestColorOffset(t *testing.T) {
assert(11, 39, 40, tui.NewColorPair(4, 8, tui.Bold))
}
}
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]))
}
+19 -12
View File
@@ -183,23 +183,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")
}
@@ -219,7 +218,7 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
apiKey = strings.TrimSpace(pair[1])
}
}
case 2:
case 2: // Request body
body += text
}
}
@@ -228,6 +227,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")
}
+722 -388
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 -1
View File
@@ -302,7 +302,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
@@ -34,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) {}
+104 -72
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}
@@ -1033,8 +1072,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,
@@ -1283,7 +1322,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")
@@ -1321,8 +1371,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
}
@@ -1336,65 +1405,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 {
@@ -1432,7 +1464,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
@@ -1454,7 +1486,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
@@ -1462,7 +1494,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",
+55 -8
View File
@@ -99,7 +99,7 @@ func (r *LightRenderer) findOffset() (row int, col int) {
var err error
bytes := []byte{}
for tries := range offsetPollTries {
bytes, err = r.getBytesInternal(bytes, tries > 0)
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
}
}
+92 -56
View File
@@ -5,6 +5,7 @@ package tui
import (
"os"
"regexp"
"strings"
"time"
"github.com/gdamore/tcell/v2"
@@ -53,6 +54,7 @@ type TcellWindow struct {
showCursor bool
wrapSign string
wrapSignWidth int
tabstop int
}
func (w *TcellWindow) Top() int {
@@ -246,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:
@@ -703,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()
@@ -729,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:
@@ -753,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
}
@@ -821,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()
@@ -829,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)
@@ -868,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()
@@ -883,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
@@ -963,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() {
+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()
+100 -9
View File
@@ -2,6 +2,7 @@ package tui
import (
"strconv"
"strings"
"time"
"github.com/junegunn/fzf/src/util"
@@ -17,15 +18,34 @@ const (
BoldForce = Attr(1 << 10)
FullBg = Attr(1 << 11)
Strip = Attr(1 << 12)
// Underline style stored in bits 13-15 (3 bits, values 0-4)
// Only meaningful when the Underline attribute bit is also set.
// 0 = solid (default)
UnderlineStyleShift = 13
UnderlineStyleMask = Attr(0b111 << UnderlineStyleShift)
UlStyleDouble = Attr(0b001 << UnderlineStyleShift)
UlStyleCurly = Attr(0b010 << UnderlineStyleShift)
UlStyleDotted = Attr(0b011 << UnderlineStyleShift)
UlStyleDashed = Attr(0b100 << UnderlineStyleShift)
)
func (a Attr) UnderlineStyle() Attr {
return a & UnderlineStyleMask
}
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
merged := (a &^ AttrRegular) | b
// When b sets Underline, use b's underline style instead of OR'ing
if b&Underline > 0 {
merged = (merged &^ UnderlineStyleMask) | (b & UnderlineStyleMask)
}
return merged
}
// Types of user action
@@ -352,6 +372,7 @@ const (
type ColorPair struct {
fg Color
bg Color
ul Color
attr Attr
}
@@ -363,11 +384,11 @@ func HexToColor(rrggbb string) Color {
}
func NewColorPair(fg Color, bg Color, attr Attr) ColorPair {
return ColorPair{fg, bg, attr}
return ColorPair{fg, bg, colDefault, attr}
}
func NoColorPair() ColorPair {
return ColorPair{-1, -1, 0}
return ColorPair{-1, -1, -1, 0}
}
func (p ColorPair) Fg() Color {
@@ -378,6 +399,16 @@ func (p ColorPair) Bg() Color {
return p.bg
}
func (p ColorPair) Ul() Color {
return p.ul
}
func (p ColorPair) WithUl(ul Color) ColorPair {
dup := p
dup.ul = ul
return dup
}
func (p ColorPair) Attr() Attr {
return p.attr
}
@@ -404,6 +435,9 @@ func (p ColorPair) merge(other ColorPair, except Color) ColorPair {
if other.bg != except {
dup.bg = other.bg
}
if other.ul != except {
dup.ul = other.ul
}
return dup
}
@@ -415,13 +449,13 @@ func (p ColorPair) WithAttr(attr Attr) ColorPair {
func (p ColorPair) WithFg(fg ColorAttr) ColorPair {
dup := p
fgPair := ColorPair{fg.Color, colUndefined, fg.Attr}
fgPair := ColorPair{fg.Color, colUndefined, colUndefined, fg.Attr}
return dup.Merge(fgPair)
}
func (p ColorPair) WithBg(bg ColorAttr) ColorPair {
dup := p
bgPair := ColorPair{colUndefined, bg.Color, bg.Attr}
bgPair := ColorPair{colUndefined, bg.Color, colUndefined, bg.Attr}
return dup.Merge(bgPair)
}
@@ -456,6 +490,7 @@ type ColorTheme struct {
PreviewBg ColorAttr
DarkBg ColorAttr
Gutter ColorAttr
AltGutter ColorAttr
Prompt ColorAttr
InputBg ColorAttr
InputBorder ColorAttr
@@ -748,7 +783,8 @@ type Renderer interface {
HideCursor()
ShowCursor()
GetChar() Event
GetChar(cancellable bool) Event
CancelGetChar()
Top() int
MaxX() int
@@ -781,7 +817,7 @@ type Window interface {
Print(text string)
CPrint(color ColorPair, text string)
Fill(text string) FillReturn
CFill(fg Color, bg Color, attr Attr, text string) FillReturn
CFill(fg Color, bg Color, ul Color, attr Attr, text string) FillReturn
LinkBegin(uri string, params string)
LinkEnd()
Erase()
@@ -794,16 +830,18 @@ type FullscreenRenderer struct {
theme *ColorTheme
mouse bool
forceBlack bool
tabstop int
prevDownTime time.Time
clicks [][2]int
showCursor bool
}
func NewFullscreenRenderer(theme *ColorTheme, forceBlack bool, mouse bool) Renderer {
func NewFullscreenRenderer(theme *ColorTheme, forceBlack bool, mouse bool, tabstop int) Renderer {
r := &FullscreenRenderer{
theme: theme,
mouse: mouse,
forceBlack: forceBlack,
tabstop: tabstop,
prevDownTime: time.Unix(0, 0),
clicks: [][2]int{},
showCursor: true}
@@ -826,6 +864,8 @@ var (
ColCursor ColorPair
ColCursorEmpty ColorPair
ColCursorEmptyChar ColorPair
ColAltCursorEmpty ColorPair
ColAltCursorEmptyChar ColorPair
ColMarker ColorPair
ColSelected ColorPair
ColSelectedMatch ColorPair
@@ -891,6 +931,7 @@ func init() {
PreviewFg: defaultColor,
PreviewBg: defaultColor,
Gutter: undefined,
AltGutter: undefined,
PreviewBorder: defaultColor,
PreviewScrollbar: defaultColor,
PreviewLabel: defaultColor,
@@ -943,6 +984,7 @@ func init() {
PreviewFg: undefined,
PreviewBg: undefined,
Gutter: undefined,
AltGutter: undefined,
PreviewBorder: undefined,
PreviewScrollbar: undefined,
PreviewLabel: undefined,
@@ -991,6 +1033,7 @@ func init() {
PreviewFg: undefined,
PreviewBg: undefined,
Gutter: undefined,
AltGutter: undefined,
PreviewBorder: undefined,
PreviewScrollbar: undefined,
PreviewLabel: undefined,
@@ -1041,6 +1084,7 @@ func init() {
PreviewFg: undefined,
PreviewBg: undefined,
Gutter: undefined,
AltGutter: undefined,
PreviewBorder: undefined,
PreviewScrollbar: undefined,
PreviewLabel: undefined,
@@ -1091,6 +1135,7 @@ func init() {
PreviewFg: undefined,
PreviewBg: undefined,
Gutter: undefined,
AltGutter: undefined,
PreviewBorder: undefined,
PreviewScrollbar: undefined,
PreviewLabel: undefined,
@@ -1208,6 +1253,7 @@ func InitTheme(theme *ColorTheme, baseTheme *ColorTheme, boldify bool, forceBlac
gutter.Attr = Dim
}
theme.Gutter = o(theme.DarkBg, gutter)
theme.AltGutter = o(theme.Gutter, theme.AltGutter)
theme.PreviewFg = o(theme.Fg, theme.PreviewFg)
theme.PreviewBg = o(theme.Bg, theme.PreviewBg)
theme.PreviewLabel = o(theme.BorderLabel, theme.PreviewLabel)
@@ -1261,7 +1307,7 @@ func initPalette(theme *ColorTheme) {
if fg.Color == colDefault && (fg.Attr&Reverse) > 0 {
bg.Color = colDefault
}
return ColorPair{fg.Color, bg.Color, fg.Attr}
return ColorPair{fg.Color, bg.Color, colDefault, fg.Attr}
}
blank := theme.ListFg
blank.Attr = AttrRegular
@@ -1277,6 +1323,8 @@ func initPalette(theme *ColorTheme) {
ColCursor = pair(theme.Cursor, theme.Gutter)
ColCursorEmpty = pair(blank, theme.Gutter)
ColCursorEmptyChar = pair(theme.Gutter, theme.ListBg)
ColAltCursorEmpty = pair(blank, theme.AltGutter)
ColAltCursorEmptyChar = pair(theme.AltGutter, theme.ListBg)
if theme.SelectedBg.Color != theme.ListBg.Color {
ColMarker = pair(theme.Marker, theme.SelectedBg)
} else {
@@ -1315,3 +1363,46 @@ func initPalette(theme *ColorTheme) {
func runeWidth(r rune) int {
return uniseg.StringWidth(string(r))
}
// WrappedLine represents a single visual line after character-level wrapping.
type WrappedLine struct {
Text string
DisplayWidth int
}
// WrapLine splits a single line (no embedded \n) into visual lines
// that fit within initialMax columns. Character-level wrapping only.
func WrapLine(input string, prefixLength int, initialMax int, tabstop int, wrapSignWidth int) []WrappedLine {
lines := []WrappedLine{}
width := 0
line := ""
gr := uniseg.NewGraphemes(input)
maxWidth := initialMax
contMax := max(1, initialMax-wrapSignWidth)
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 = strings.Repeat(" ", w)
} else if rs[0] == '\r' {
w++
} else {
w = uniseg.StringWidth(str)
}
width += w
if prefixLength+width <= maxWidth {
line += str
} else {
lines = append(lines, WrappedLine{string(line), width - w})
line = str
prefixLength = 0
width = w
maxWidth = contMax
}
}
lines = append(lines, WrappedLine{string(line), width})
return lines
}
+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)
+15 -2
View File
@@ -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())
@@ -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() {
+17
View File
@@ -0,0 +1,17 @@
# 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_TRIGGER FZF_COMPLETION_OPTS
set -gx FZF_DEFAULT_OPTS "--no-scrollbar --pointer '>' --marker '>'"
set -gx FZF_COMPLETION_TRIGGER '++'
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"
+12 -1
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
@@ -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
@@ -153,6 +163,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
+114 -11
View File
@@ -1191,7 +1191,7 @@ class TestCore < TestInteractive
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.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]) }
@@ -1202,21 +1202,21 @@ class TestCore < TestInteractive
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.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]) }
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.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]) }
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
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) }
@@ -1228,7 +1228,7 @@ class TestCore < TestInteractive
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.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)
@@ -1241,7 +1241,7 @@ class TestCore < TestInteractive
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') }
assert_equal(1, lines.count { |l| l.end_with?('g') })
end
end
@@ -1588,14 +1588,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
@@ -1605,7 +1607,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
@@ -1617,16 +1620,33 @@ 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_one_and_zero
@@ -1735,7 +1755,7 @@ 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',
@@ -2155,4 +2175,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
+38 -1
View File
@@ -383,6 +383,16 @@ 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_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 +551,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 +582,31 @@ 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[33m1234567890 \\x1b[mhello"; echo -e "\\x1b[33m1234567890 \\x1b[mhello"' --preview-window 2,wrap-word), :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[33m1234567890 \\x1b[mhello"; echo -e "\\x1b[33m1234567890 \\x1b[mhello"' --preview-window 1,wrap-word), :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
end
+13 -3
View File
@@ -31,22 +31,32 @@ class TestServer < TestInteractive
end
def test_listen_with_api_key
post_uri = URI('http://localhost:6266')
uri = URI('http://localhost:6266')
tmux.send_keys 'seq 10 | FZF_API_KEY=123abc fzf --listen 6266', :Enter
tmux.until { |lines| assert_equal 10, lines.match_count }
# Incorrect API Key
[nil, { 'x-api-key' => '' }, { 'x-api-key' => '124abc' }].each do |headers|
res = Net::HTTP.post(post_uri, 'change-query(yo)+reload(seq 100)+change-prompt:hundred> ', headers)
res = Net::HTTP.post(uri, 'change-query(yo)+reload(seq 100)+change-prompt:hundred> ', headers)
assert_equal '401', res.code
assert_equal 'Unauthorized', res.message
assert_equal "invalid api key\n", res.body
res = Net::HTTP.get_response(uri, headers)
assert_equal '401', res.code
assert_equal 'Unauthorized', res.message
assert_equal "invalid api key\n", res.body
end
# Valid API Key
[{ 'x-api-key' => '123abc' }, { 'X-API-Key' => '123abc' }].each do |headers|
res = Net::HTTP.post(post_uri, 'change-query(yo)+reload(seq 100)+change-prompt:hundred> ', headers)
res = Net::HTTP.post(uri, 'change-query(yo)+reload(seq 100)+change-prompt:hundred> ', headers)
assert_equal '200', res.code
tmux.until { |lines| assert_equal 100, lines.item_count }
tmux.until { |lines| assert_equal 'hundred> yo', lines[-1] }
res = Net::HTTP.get_response(uri, headers)
assert_equal '200', res.code
assert_equal 'yo', JSON.parse(res.body, symbolize_names: true)[:query]
end
end
end
+614 -76
View File
@@ -27,6 +27,10 @@ module TestShell
tmux.prepare
end
def trigger
'**'
end
def test_ctrl_t
set_var('FZF_CTRL_T_COMMAND', 'seq 100')
@@ -134,7 +138,7 @@ module TestShell
tmux.send_keys 'C-r'
tmux.until { |lines| assert_equal '>', lines[-1] }
tmux.send_keys 'foo bar'
tmux.until { |lines| assert_includes lines[-4], '"foo' } unless shell == :zsh
tmux.until { |lines| assert_includes lines[-4], '"foo' } if shell == :bash
tmux.until { |lines| assert lines[-3]&.match?(/bar"␊?/) }
tmux.send_keys :Enter
tmux.until { |lines| assert lines[-1]&.match?(/bar"␊?/) }
@@ -165,7 +169,11 @@ module CompletionTest
FileUtils.touch(File.expand_path(f))
end
tmux.prepare
tmux.send_keys 'cat /tmp/fzf-test/10**', :Tab
if shell == :fish
tmux.send_keys 'cat /tmp/fzf-test/10', 'C-t'
else
tmux.send_keys "cat /tmp/fzf-test/10#{trigger}", :Tab
end
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys ' !d'
tmux.until { |lines| assert_equal 2, lines.match_count }
@@ -179,7 +187,11 @@ module CompletionTest
# ~USERNAME**<TAB>
user = `whoami`.chomp
tmux.send_keys 'C-u'
tmux.send_keys "cat ~#{user}**", :Tab
if shell == :fish
tmux.send_keys "cat ~#{user}", 'C-t'
else
tmux.send_keys "cat ~#{user}#{trigger}", :Tab
end
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys "/#{user}"
tmux.until { |lines| assert(lines.any? { |l| l.end_with?("/#{user}") }) }
@@ -190,14 +202,29 @@ module CompletionTest
# ~INVALID_USERNAME**<TAB>
tmux.send_keys 'C-u'
tmux.send_keys 'cat ~such**', :Tab
if shell == :fish
tmux.send_keys 'cat ~such', 'C-t'
else
tmux.send_keys "cat ~such#{trigger}", :Tab
end
tmux.until(true) { |lines| assert lines.any_include?('no~such~user') }
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'cat no~such~user', lines[-1] }
tmux.until(true) do |lines|
if shell == :fish
# Fish's string escape quotes filenames with ~ to prevent tilde expansion
assert_equal 'cat no\\~such\\~user', lines[-1]
else
assert_equal 'cat no~such~user', lines[-1]
end
end
# /tmp/fzf\ test**<TAB>
tmux.send_keys 'C-u'
tmux.send_keys 'cat /tmp/fzf\ test/**', :Tab
if shell == :fish
tmux.send_keys 'cat /tmp/fzf\\ test/', 'C-t'
else
tmux.send_keys "cat /tmp/fzf\\ test/#{trigger}", :Tab
end
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys 'foobar$'
tmux.until do |lines|
@@ -210,7 +237,11 @@ module CompletionTest
# Should include hidden files
(1..100).each { |i| FileUtils.touch("/tmp/fzf-test/.hidden-#{i}") }
tmux.send_keys 'C-u'
tmux.send_keys 'cat /tmp/fzf-test/hidden**', :Tab
if shell == :fish
tmux.send_keys 'cat /tmp/fzf-test/hidden', 'C-t'
else
tmux.send_keys "cat /tmp/fzf-test/hidden#{trigger}", :Tab
end
tmux.until(true) do |lines|
assert_equal 100, lines.match_count
assert lines.any_include?('/tmp/fzf-test/.hidden-')
@@ -223,70 +254,89 @@ module CompletionTest
end
def test_file_completion_root
tmux.send_keys 'ls /**', :Tab
if shell == :fish
tmux.send_keys 'ls /', 'C-t'
else
tmux.send_keys "ls /#{trigger}", :Tab
end
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys :Enter
end
def test_dir_completion
FileUtils.mkdir_p('/tmp/fzf-test-dir')
(1..100).each do |idx|
FileUtils.mkdir_p("/tmp/fzf-test/d#{idx}")
FileUtils.mkdir_p("/tmp/fzf-test-dir/d#{idx}")
end
FileUtils.touch('/tmp/fzf-test/d55/xxx')
FileUtils.touch('/tmp/fzf-test-dir/d55/xxx')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test/**', :Tab
if shell == :fish
tmux.send_keys 'cd /tmp/fzf-test-dir/', 'C-t'
else
tmux.send_keys "cd /tmp/fzf-test-dir/#{trigger}", :Tab
end
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys :Tab, :Tab # Tab does not work here
tmux.send_keys 55
# Tab selects items in C-t's --multi mode, so skip for fish
tmux.send_keys :Tab, :Tab unless shell == :fish # Tab does not work here
tmux.send_keys '55/$'
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_includes lines, '> 55'
assert_includes lines, '> /tmp/fzf-test/d55/'
assert_includes lines, '> 55/$'
assert_includes lines, '> /tmp/fzf-test-dir/d55/'
end
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'cd /tmp/fzf-test/d55/', lines[-1] }
tmux.until(true) { |lines| assert_equal 'cd /tmp/fzf-test-dir/d55/', lines[-1] }
# C-t appends a trailing space after the result
tmux.send_keys :BSpace if shell == :fish
tmux.send_keys :xx
tmux.until { |lines| assert_equal 'cd /tmp/fzf-test/d55/xx', lines[-1] }
tmux.until { |lines| assert_equal 'cd /tmp/fzf-test-dir/d55/xx', lines[-1] }
# Should not match regular files (bash-only)
if instance_of?(TestBash)
tmux.send_keys :Tab
tmux.until { |lines| assert_equal 'cd /tmp/fzf-test/d55/xx', lines[-1] }
tmux.until { |lines| assert_equal 'cd /tmp/fzf-test-dir/d55/xx', lines[-1] }
end
# Fail back to plusdirs
tmux.send_keys :BSpace, :BSpace, :BSpace
tmux.until { |lines| assert_equal 'cd /tmp/fzf-test/d55', lines[-1] }
tmux.until { |lines| assert_equal 'cd /tmp/fzf-test-dir/d55', lines[-1] }
tmux.send_keys :Tab
tmux.until { |lines| assert_equal 'cd /tmp/fzf-test/d55/', lines[-1] }
tmux.until { |lines| assert_equal 'cd /tmp/fzf-test-dir/d55/', lines[-1] }
ensure
FileUtils.rm_rf('/tmp/fzf-test-dir')
end
def test_process_completion
tmux.send_keys 'sleep 12345 &', :Enter
lines = tmux.until { |lines| assert lines[-1]&.start_with?('[1] ') }
pid = lines[-1]&.split&.last
tmux.prepare
tmux.send_keys 'C-L'
tmux.send_keys 'kill **', :Tab
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys 'sleep12345'
tmux.until { |lines| assert lines.any_include?('sleep 12345') }
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal "kill #{pid}", lines[-1] }
ensure
if pid
begin
Process.kill('KILL', pid.to_i)
rescue StandardError
nil
skip('fish background job format differs') if shell == :fish
begin
tmux.send_keys 'sleep 12345 &', :Enter
lines = tmux.until { |lines| assert lines[-1]&.start_with?('[1] ') }
pid = lines[-1]&.split&.last
tmux.prepare
tmux.send_keys 'C-L'
tmux.send_keys "kill #{trigger}", :Tab
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys 'sleep12345'
tmux.until { |lines| assert lines.any_include?('sleep 12345') }
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal "kill #{pid}", lines[-1] }
ensure
if pid
begin
Process.kill('KILL', pid.to_i)
rescue StandardError
nil
end
end
end
end
def test_custom_completion
skip('fish does not use _fzf_compgen_path; path completion is via ctrl-t') if shell == :fish
tmux.send_keys '_fzf_compgen_path() { echo "$1"; seq 10; }', :Enter
tmux.prepare
tmux.send_keys 'ls /tmp/**', :Tab
tmux.send_keys "ls /tmp/#{trigger}", :Tab
tmux.until { |lines| assert_equal 11, lines.match_count }
tmux.send_keys :Tab, :Tab, :Tab
tmux.until { |lines| assert_equal 3, lines.select_count }
@@ -295,11 +345,12 @@ module CompletionTest
end
def test_unset_completion
skip('fish has native completion for set and unset variables') if shell == :fish
tmux.send_keys 'export FZFFOOBAR=BAZ', :Enter
tmux.prepare
# Using tmux
tmux.send_keys 'unset FZFFOOBR**', :Tab
tmux.send_keys "unset FZFFOOBR#{trigger}", :Tab
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
tmux.until { |lines| assert_equal 'unset FZFFOOBAR', lines[-1] }
@@ -308,34 +359,57 @@ module CompletionTest
# FZF_TMUX=1
new_shell
tmux.focus
tmux.send_keys 'unset FZFFOOBR**', :Tab
tmux.send_keys "unset FZFFOOBR#{trigger}", :Tab
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
tmux.until { |lines| assert_equal 'unset FZFFOOBAR', lines[-1] }
end
def test_completion_in_command_sequence
tmux.send_keys 'export FZFFOOBAR=BAZ', :Enter
tmux.prepare
triggers = ['**', '~~', '++', 'ff', '/']
triggers.push('&', '[', ';', '`') if instance_of?(TestZsh)
triggers.each do |trigger|
set_var('FZF_COMPLETION_TRIGGER', trigger)
command = "echo foo; QUX=THUD unset FZFFOOBR#{trigger}"
tmux.send_keys command.sub(/(;|`)$/, '\\\\\1'), :Tab
if shell == :fish
FileUtils.mkdir_p('/tmp/fzf-test-seq')
FileUtils.touch('/tmp/fzf-test-seq/fzffoobar')
tmux.prepare
# Fish uses Shift-Tab for fzf completion (no trigger system)
command = 'echo foo; QUX=THUD ls /tmp/fzf-test-seq/fzffoobr'
expected = 'echo foo; QUX=THUD ls /tmp/fzf-test-seq/fzffoobar'
tmux.send_keys command, :BTab
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
tmux.until { |lines| assert_equal 'echo foo; QUX=THUD unset FZFFOOBAR', lines[-1] }
tmux.until { |lines| assert_equal expected, lines[-1] }
else
tmux.send_keys 'export FZFFOOBAR=BAZ', :Enter
tmux.prepare
triggers = ['**', '~~', '++', 'ff', '/']
triggers.push('&', '[', ';', '`') if instance_of?(TestZsh)
triggers.each do |trigger|
set_var('FZF_COMPLETION_TRIGGER', trigger)
command = "echo foo; QUX=THUD unset FZFFOOBR#{trigger}"
expected = 'echo foo; QUX=THUD unset FZFFOOBAR'
tmux.send_keys command.sub(/(;|`)$/, '\\\\\1'), :Tab
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
tmux.until { |lines| assert_equal expected, lines[-1] }
end
end
ensure
FileUtils.rm_rf('/tmp/fzf-test-seq') if shell == :fish
end
def test_file_completion_unicode
FileUtils.mkdir_p('/tmp/fzf-test')
tmux.paste "cd /tmp/fzf-test; echo test3 > $'fzf-unicode \\355\\205\\214\\354\\212\\244\\355\\212\\2701'; echo test4 > $'fzf-unicode \\355\\205\\214\\354\\212\\244\\355\\212\\2702'"
# Shell-agnostic file creation
File.write('/tmp/fzf-test/fzf-unicode 테스트1', "test3\n")
File.write('/tmp/fzf-test/fzf-unicode 테스트2', "test4\n")
tmux.send_keys 'cd /tmp/fzf-test', :Enter
tmux.prepare
tmux.send_keys 'cat fzf-unicode**', :Tab
if shell == :fish
tmux.send_keys 'cat fzf-unicode', 'C-t'
else
tmux.send_keys "cat fzf-unicode#{trigger}", :Tab
end
tmux.until { |lines| assert_equal 2, lines.match_count }
tmux.send_keys '1'
@@ -358,36 +432,41 @@ module CompletionTest
end
def test_custom_completion_api
tmux.send_keys 'eval "_fzf$(declare -f _comprun)"', :Enter
%w[f g].each do |command|
tmux.prepare
tmux.send_keys "#{command} b**", :Tab
tmux.until do |lines|
assert_equal 2, lines.item_count
assert_equal 1, lines.match_count
assert lines.any_include?("prompt-#{command}")
assert lines.any_include?("preview-#{command}-bar")
skip('bash-specific _comprun/declare syntax') if shell == :fish
begin
tmux.send_keys 'eval "_fzf$(declare -f _comprun)"', :Enter
%w[f g].each do |command|
tmux.prepare
tmux.send_keys "#{command} b#{trigger}", :Tab
tmux.until do |lines|
assert_equal 2, lines.item_count
assert_equal 1, lines.match_count
assert lines.any_include?("prompt-#{command}")
assert lines.any_include?("preview-#{command}-bar")
end
tmux.send_keys :Enter
tmux.until { |lines| assert_equal "#{command} #{command}barbar", lines[-1] }
tmux.send_keys 'C-u'
end
tmux.send_keys :Enter
tmux.until { |lines| assert_equal "#{command} #{command}barbar", lines[-1] }
tmux.send_keys 'C-u'
ensure
tmux.prepare
tmux.send_keys 'unset -f _fzf_comprun', :Enter
end
ensure
tmux.prepare
tmux.send_keys 'unset -f _fzf_comprun', :Enter
end
def test_ssh_completion
skip('fish uses native ssh completion') if shell == :fish
(1..5).each { |i| FileUtils.touch("/tmp/fzf-test-ssh-#{i}") }
tmux.send_keys 'ssh jg@localhost**', :Tab
tmux.send_keys "ssh jg@localhost#{trigger}", :Tab
tmux.until do |lines|
assert_operator lines.match_count, :>=, 1
end
tmux.send_keys :Enter
tmux.until { |lines| assert lines.any_include?('ssh jg@localhost') }
tmux.send_keys ' -i /tmp/fzf-test-ssh**', :Tab
tmux.send_keys " -i /tmp/fzf-test-ssh#{trigger}", :Tab
tmux.until do |lines|
assert_operator lines.match_count, :>=, 5
assert_equal 0, lines.select_count
@@ -399,11 +478,344 @@ module CompletionTest
tmux.send_keys :Enter
tmux.until { |lines| assert lines.any_include?('ssh jg@localhost -i /tmp/fzf-test-ssh-') }
tmux.send_keys 'localhost**', :Tab
tmux.send_keys "localhost#{trigger}", :Tab
tmux.until do |lines|
assert_operator lines.match_count, :>=, 1
end
end
def test_option_equals_long_option
FileUtils.mkdir_p('/tmp/fzf-test-opt-eq-long')
FileUtils.touch('/tmp/fzf-test-opt-eq-long/SECURITY.md')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-opt-eq-long', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'some-command --opt=SECURI', 'C-t'
else
tmux.send_keys "some-command --opt=SECURI#{trigger}", :Tab
end
case shell
when :bash
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_includes lines, '> SECURI'
end
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'some-command --opt=SECURITY.md', lines[-1] }
when :fish
tmux.until do |lines|
assert_equal 1, lines.match_count
assert lines.any_include?('SECURITY.md')
end
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'some-command --opt=SECURITY.md', lines[-1] }
when :zsh
tmux.until do |lines|
assert_equal 0, lines.match_count
assert_includes lines, '> --opt=SECURI'
end
end
ensure
FileUtils.rm_rf('/tmp/fzf-test-opt-eq-long')
end
def test_option_equals_long_option_after_double_dash
FileUtils.mkdir_p('/tmp/fzf-test-opt-eq-long-ddash')
FileUtils.touch('/tmp/fzf-test-opt-eq-long-ddash/SECURITY.md')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-opt-eq-long-ddash', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'some-command -- --opt=SECURI', 'C-t'
else
tmux.send_keys "some-command -- --opt=SECURI#{trigger}", :Tab
end
case shell
when :bash
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_includes lines, '> SECURI'
end
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'some-command -- --opt=SECURITY.md', lines[-1] }
when :fish, :zsh
tmux.until do |lines|
assert_equal 0, lines.match_count
assert_includes lines, '> --opt=SECURI'
end
end
ensure
FileUtils.rm_rf('/tmp/fzf-test-opt-eq-long-ddash')
end
def test_option_equals_short_option
FileUtils.mkdir_p('/tmp/fzf-test-opt-eq-short')
FileUtils.touch('/tmp/fzf-test-opt-eq-short/SECURITY.md')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-opt-eq-short', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'some-command -o=SECURI', 'C-t'
else
tmux.send_keys "some-command -o=SECURI#{trigger}", :Tab
end
case shell
when :bash, :fish
tmux.until do |lines|
assert_equal 1, lines.match_count
assert lines.any_include?('> SECURITY.md')
end
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'some-command -o=SECURITY.md', lines[-1] }
when :zsh
tmux.until do |lines|
assert_equal 0, lines.match_count
assert_includes lines, '> -o=SECURI'
end
end
ensure
FileUtils.rm_rf('/tmp/fzf-test-opt-eq-short')
end
def test_option_equals_short_option_after_double_dash
FileUtils.mkdir_p('/tmp/fzf-test-opt-eq-short-ddash')
FileUtils.touch('/tmp/fzf-test-opt-eq-short-ddash/SECURITY.md')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-opt-eq-short-ddash', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'some-command -- -o=SECURI', 'C-t'
else
tmux.send_keys "some-command -- -o=SECURI#{trigger}", :Tab
end
case shell
when :bash
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_includes lines, '> SECURITY.md'
end
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'some-command -- -o=SECURITY.md', lines[-1] }
when :fish, :zsh
tmux.until do |lines|
assert_equal 0, lines.match_count
assert_includes lines, '> -o=SECURI'
end
end
ensure
FileUtils.rm_rf('/tmp/fzf-test-opt-eq-short-ddash')
end
def test_option_no_equals_long_option
FileUtils.mkdir_p('/tmp/fzf-test-opt-no-eq-long')
FileUtils.touch('/tmp/fzf-test-opt-no-eq-long/SECURITY.md')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-opt-no-eq-long', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'some-command --optSECURI', 'C-t'
else
tmux.send_keys "some-command --optSECURI#{trigger}", :Tab
end
tmux.until do |lines|
assert_equal 0, lines.match_count
assert_includes lines, '> --optSECURI'
end
ensure
FileUtils.rm_rf('/tmp/fzf-test-opt-no-eq-long')
end
def test_option_no_equals_long_option_after_double_dash
FileUtils.mkdir_p('/tmp/fzf-test-opt-no-eq-long-ddash')
FileUtils.touch('/tmp/fzf-test-opt-no-eq-long-ddash/SECURITY.md')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-opt-no-eq-long-ddash', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'some-command -- --optSECURI', 'C-t'
else
tmux.send_keys "some-command -- --optSECURI#{trigger}", :Tab
end
tmux.until do |lines|
assert_equal 0, lines.match_count
assert_includes lines, '> --optSECURI'
end
ensure
FileUtils.rm_rf('/tmp/fzf-test-opt-no-eq-long-ddash')
end
def test_option_no_equals_short_option
FileUtils.mkdir_p('/tmp/fzf-test-opt-no-eq-short')
FileUtils.touch('/tmp/fzf-test-opt-no-eq-short/SECURITY.md')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-opt-no-eq-short', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'some-command -oSECURI', 'C-t'
else
tmux.send_keys "some-command -oSECURI#{trigger}", :Tab
end
case shell
when :bash, :zsh
tmux.until do |lines|
assert_equal 0, lines.match_count
assert_includes lines, '> -oSECURI'
end
when :fish
tmux.until do |lines|
assert_equal 1, lines.match_count
assert lines.any_include?('> SECURITY.md')
end
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'some-command -oSECURITY.md', lines[-1] }
end
ensure
FileUtils.rm_rf('/tmp/fzf-test-opt-no-eq-short')
end
def test_option_no_equals_short_option_after_double_dash
FileUtils.mkdir_p('/tmp/fzf-test-opt-no-eq-short-ddash')
FileUtils.touch('/tmp/fzf-test-opt-no-eq-short-ddash/SECURITY.md')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-opt-no-eq-short-ddash', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'some-command -- -oSECURI', 'C-t'
else
tmux.send_keys "some-command -- -oSECURI#{trigger}", :Tab
end
tmux.until do |lines|
assert_equal 0, lines.match_count
assert_includes lines, '> -oSECURI'
end
ensure
FileUtils.rm_rf('/tmp/fzf-test-opt-no-eq-short-ddash')
end
def test_filename_with_newline
FileUtils.mkdir_p('/tmp/fzf-test-newline')
FileUtils.touch("/tmp/fzf-test-newline/xyz\nwith\nnewlines")
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-newline', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'cat xyz', 'C-t'
else
tmux.send_keys "cat xyz#{trigger}", :Tab
end
case shell
when :fish
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_includes lines, '> xyz'
end
tmux.send_keys :Enter
# fish escapes newlines in filenames
tmux.until(true) { |lines| assert_equal 'cat xyz\\nwith\\nnewlines', lines[-1] }
when :bash, :zsh
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_includes lines, '> xyz'
end
tmux.send_keys :Enter
# bash and zsh replace newlines with spaces in filenames
tmux.until(true) { |lines| assert_equal 'cat xyz with newlines', lines[-1] }
end
ensure
FileUtils.rm_rf('/tmp/fzf-test-newline')
end
def test_path_with_special_chars
FileUtils.mkdir_p('/tmp/fzf-test-[special]')
FileUtils.touch('/tmp/fzf-test-[special]/xyz123')
tmux.prepare
if shell == :fish
tmux.send_keys 'ls /tmp/fzf-test-\[special\]/xyz', 'C-t'
else
tmux.send_keys "ls /tmp/fzf-test-\\[special\\]/xyz#{trigger}", :Tab
end
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'ls /tmp/fzf-test-\\[special\\]/xyz123', lines[-1] }
ensure
FileUtils.rm_rf('/tmp/fzf-test-[special]')
end
def test_query_with_dollar_anchor
FileUtils.mkdir_p('/tmp/fzf-test-dollar-anchor')
FileUtils.touch('/tmp/fzf-test-dollar-anchor/file.txt')
FileUtils.touch('/tmp/fzf-test-dollar-anchor/filetxt.md')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-dollar-anchor', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'ls txt$', 'C-t'
else
tmux.send_keys "ls txt$#{trigger}", :Tab
end
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_includes lines, '> txt$'
end
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'ls file.txt', lines[-1] }
ensure
FileUtils.rm_rf('/tmp/fzf-test-dollar-anchor')
end
def test_single_flag_completion
FileUtils.mkdir_p('/tmp/fzf-test-single-flag')
FileUtils.touch('/tmp/fzf-test-single-flag/-testfile.txt')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-single-flag', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'ls -', 'C-t'
else
tmux.send_keys "ls -#{trigger}", :Tab
end
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_includes lines, '> -'
end
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'ls -testfile.txt', lines[-1] }
ensure
FileUtils.rm_rf('/tmp/fzf-test-single-flag')
end
def test_double_flag_completion
FileUtils.mkdir_p('/tmp/fzf-test-double-flag')
FileUtils.touch('/tmp/fzf-test-double-flag/--testfile.txt')
tmux.prepare
tmux.send_keys 'cd /tmp/fzf-test-double-flag', :Enter
tmux.prepare
if shell == :fish
tmux.send_keys 'ls --', 'C-t'
else
tmux.send_keys "ls --#{trigger}", :Tab
end
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_includes lines, '> --'
end
tmux.send_keys :Enter
tmux.until(true) { |lines| assert_equal 'ls --testfile.txt', lines[-1] }
ensure
FileUtils.rm_rf('/tmp/fzf-test-double-flag')
end
end
class TestBash < TestBase
@@ -424,7 +836,7 @@ class TestBash < TestBase
tmux.paste 'touch /tmp/foo; _fzf_completion_loader=1'
tmux.paste '_completion_loader() { complete -o default fake; }'
tmux.paste 'complete -F _fzf_path_completion -o default -o bashdefault fake'
tmux.send_keys 'fake /tmp/foo**', :Tab
tmux.send_keys "fake /tmp/foo#{trigger}", :Tab
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys 'C-c'
@@ -433,7 +845,7 @@ class TestBash < TestBase
tmux.send_keys :Tab, 'C-u'
tmux.prepare
tmux.send_keys 'fake /tmp/foo**', :Tab
tmux.send_keys "fake /tmp/foo#{trigger}", :Tab
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
end
end
@@ -455,24 +867,142 @@ class TestZsh < TestBase
tmux.send_keys 'export FZFFOOBAR=BAZ', :Enter
['unset', '\unset', "'unset'"].each do |command|
tmux.prepare
tmux.send_keys "#{command} FZFFOOBR**", :Tab
tmux.send_keys "#{command} FZFFOOBR#{trigger}", :Tab
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
tmux.until { |lines| assert_equal "#{command} FZFFOOBAR", lines[-1] }
tmux.send_keys 'C-c'
end
end
# Helper function to run test with Perl and again with Awk
def self.test_perl_and_awk(name, &block)
define_method(:"test_#{name}") do
instance_eval(&block)
end
define_method(:"test_#{name}_awk") do
tmux.send_keys "unset 'commands[perl]'", :Enter
tmux.prepare
# Verify perl is actually unset (0 = not found)
tmux.send_keys 'echo ${+commands[perl]}', :Enter
tmux.until { |lines| assert_equal '0', lines[-1] }
tmux.prepare
instance_eval(&block)
end
end
def prepare_ctrl_r_test
tmux.send_keys ':', :Enter
tmux.send_keys 'echo match-collision', :Enter
tmux.prepare
tmux.send_keys 'echo "line 1', :Enter, '2 line 2"', :Enter
tmux.prepare
tmux.send_keys 'echo "foo', :Enter, 'bar"', :Enter
tmux.prepare
tmux.send_keys 'echo "bar', :Enter, 'foo"', :Enter
tmux.prepare
tmux.send_keys 'echo "trailing_space "', :Enter
tmux.prepare
tmux.send_keys 'cat <<EOF | wc -c', :Enter, 'qux thud', :Enter, 'EOF', :Enter
tmux.prepare
tmux.send_keys 'C-l', 'C-r'
end
test_perl_and_awk 'ctrl_r_accept_or_print_query' do
set_var('FZF_CTRL_R_OPTS', '--bind enter:accept-or-print-query')
prepare_ctrl_r_test
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys '1 foobar'
tmux.until { |lines| assert_equal 0, lines.match_count }
tmux.send_keys :Enter
tmux.until { |lines| assert_equal '1 foobar', lines[-1] }
end
test_perl_and_awk 'ctrl_r_multiline_index_collision' do
# Leading number in multi-line history content is not confused with index
prepare_ctrl_r_test
tmux.send_keys "'line 1"
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
tmux.until do |lines|
assert_equal ['echo "line 1', '2 line 2"'], lines[-2..]
end
end
test_perl_and_awk 'ctrl_r_multi_selection' do
prepare_ctrl_r_test
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys :BTab, :BTab, :BTab
tmux.until { |lines| assert_includes lines[-2], '(3)' }
tmux.send_keys :Enter
tmux.until do |lines|
assert_equal ['cat <<EOF | wc -c', 'qux thud', 'EOF', 'echo "trailing_space "', 'echo "bar', 'foo"'], lines[-6..]
end
end
test_perl_and_awk 'ctrl_r_no_multi_selection' do
set_var('FZF_CTRL_R_OPTS', '--no-multi')
prepare_ctrl_r_test
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys :BTab, :BTab, :BTab
tmux.until { |lines| refute_includes lines[-2], '(3)' }
tmux.send_keys :Enter
tmux.until do |lines|
assert_equal ['cat <<EOF | wc -c', 'qux thud', 'EOF'], lines[-3..]
end
end
# NOTE: 'Perl/$history' won't see foreign cmds immediately, unlike 'awk/fc'.
# Perl passes only because another cmd runs between mocking and triggering C-r
# https://github.com/junegunn/fzf/issues/4061
# https://zsh.org/mla/users/2024/msg00692.html
test_perl_and_awk 'ctrl_r_foreign_commands' do
histfile = "#{tempname}-foreign-hist"
tmux.send_keys "HISTFILE=#{histfile}", :Enter
tmux.prepare
# SHARE_HISTORY picks up foreign commands; marked with * in fc
tmux.send_keys 'setopt SHARE_HISTORY', :Enter
tmux.prepare
tmux.send_keys 'fzf_cmd_local', :Enter
tmux.prepare
# Mock foreign command (for testing only; don't edit your HISTFILE this way)
tmux.send_keys "echo ': 0:0;fzf_cmd_foreign' >> $HISTFILE", :Enter
tmux.prepare
# Verify fc shows foreign command with asterisk
tmux.send_keys 'fc -rl -1', :Enter
tmux.until { |lines| assert(lines.any? { |l| l.match?(/^\s*\d+\* fzf_cmd_foreign/) }) }
tmux.prepare
# Test ctrl-r correctly extracts the foreign command
tmux.send_keys 'C-r'
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys '^fzf_cmd_'
tmux.until { |lines| assert_equal 2, lines.match_count }
tmux.send_keys :BTab, :BTab
tmux.until { |lines| assert_includes lines[-2], '(2)' }
tmux.send_keys :Enter
tmux.until do |lines|
assert_equal %w[fzf_cmd_foreign fzf_cmd_local], lines[-2..]
end
ensure
FileUtils.rm_f(histfile)
end
end
class TestFish < TestBase
include TestShell
include CompletionTest
def shell
:fish
end
def trigger
'++'
end
def new_shell
tmux.send_keys 'env FZF_TMUX=1 FZF_DEFAULT_OPTS=--no-scrollbar fish', :Enter
tmux.send_keys 'env FZF_TMUX=1 XDG_CONFIG_HOME=/tmp/fzf-fish fish', :Enter
tmux.send_keys 'function fish_prompt; end; clear', :Enter
tmux.until { |lines| assert_empty lines }
end
@@ -490,15 +1020,23 @@ class TestFish < TestBase
tmux.send_keys 'echo "bar', :Enter, 'foo"', :Enter
tmux.prepare
tmux.send_keys 'C-l', 'C-r'
offset = -6
block = <<~BLOCK
echo "foo
bar"
echo "bar
foo"
BLOCK
if shell == :fish
offset = -4
block = <<~FISH
echo "foo␊bar"
echo "bar␊foo"
FISH
end
tmux.until do |lines|
block.lines.each_with_index do |line, idx|
assert_includes lines[-6 + idx], line.chomp
assert_includes lines[idx + offset], line.chomp
end
end
tmux.send_keys :BTab, :BTab
+6
View File
@@ -33,6 +33,9 @@ for opt in "$@"; do
esac
done
cd "$(dirname "${BASH_SOURCE[0]}")"
fzf_base=$(pwd)
ask() {
while true; do
read -p "$1 ([y]/n) " -r
@@ -94,12 +97,15 @@ done
bind_file="${fish_dir}/functions/fish_user_key_bindings.fish"
if [ -f "$bind_file" ]; then
remove_line "$bind_file" "fzf_key_bindings"
remove_line "$bind_file" "fzf_completion_setup"
remove_line "$bind_file" "fzf --fish | source"
fi
if [ -d "${fish_dir}/functions" ]; then
remove "${fish_dir}/functions/fzf.fish"
remove "${fish_dir}/functions/fzf_key_bindings.fish"
remove_line "$bind_file" "source \"${fzf_base}/shell/completion.fish\""
remove_line "$bind_file" "source \"${fzf_base}/shell/key-bindings.fish\""
if [ -z "$(ls -A "${fish_dir}/functions")" ]; then
rmdir "${fish_dir}/functions"