Compare commits

...

18 Commits

Author SHA1 Message Date
Junegunn Choi
b59f27ef5a Fix --with-shell not handling quoted arguments correctly
Some checks failed
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
Fix #4709

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

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

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

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

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

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

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

Now the performance scales linearly to the number of threads:

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-02 23:58:37 +09:00
Laurent Cheylus
d57ed157ad Remove tmppath pledge on OpenBSD (#4699)
"tmppath" pledge is no longer supported.
See commit c883e836f4

Signed-off-by: Laurent Cheylus <foxy@free.fr>
2026-03-02 22:55:13 +09:00
Junegunn Choi
9226bc605d Fix typos CI failure by excluding .s files 2026-03-02 22:49:54 +09:00
30 changed files with 352 additions and 208 deletions

View File

@@ -12,6 +12,6 @@ jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v5
- uses: actions/labeler@v6
with:
configuration-path: .github/labeler.yml

View File

@@ -1,6 +1,27 @@
CHANGELOG
=========
0.70.1
------
- Performance improvements
- The search performance now scales linearly with the number of CPU cores, as we dropped static partitioning to allow better load balancing across threads.
```
=== query: 'linux' ===
[all] baseline: 17.12ms current: 14.28ms (1.20x) matches: 179966 (12.79%)
[1T] baseline: 136.49ms current: 137.25ms (0.99x) matches: 179966 (12.79%)
[2T] baseline: 75.74ms current: 68.75ms (1.10x) matches: 179966 (12.79%)
[4T] baseline: 41.16ms current: 34.97ms (1.18x) matches: 179966 (12.79%)
[8T] baseline: 32.82ms current: 17.79ms (1.84x) matches: 179966 (12.79%)
```
- Improved the cache structure, reducing memory footprint per entry by 86x.
- With the reduced per-entry cost, the cache now has broader coverage.
- fish: Improved command history (CTRL-R) (#44703) (@bitraid)
- Bug fixes
- Fixed AWK tokenizer not treating a new line character as whitespace
- Fixed `--{accept,with}-nth` removing trailing whitespaces with a non-default `--delimiter`
- Fixed OSC8 hyperlinks being mangled when the URL contains unicode characters (#4707)
- Fixed `--with-shell` not handling quoted arguments correctly (#4709)
0.70.0
------
- Added `change-with-nth` action for dynamically changing the `--with-nth` option.

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1,9 @@
__fzf_defaults() {
# $1: Prepend to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
# $2: Append to FZF_DEFAULT_OPTS_FILE and FZF_DEFAULT_OPTS
printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
}
__fzf_exec_awk() {

View File

@@ -102,9 +102,9 @@ if [[ -o interactive ]]; then
# the changes. See code comments in "common.sh" for the implementation details.
__fzf_defaults() {
printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
}
__fzf_exec_awk() {

View File

@@ -25,9 +25,9 @@ if [[ $- =~ i ]]; then
# the changes. See code comments in "common.sh" for the implementation details.
__fzf_defaults() {
printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
}
__fzf_exec_awk() {

View File

@@ -45,9 +45,9 @@ if [[ -o interactive ]]; then
# the changes. See code comments in "common.sh" for the implementation details.
__fzf_defaults() {
printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
builtin printf '%s\n' "--height ${FZF_TMUX_HEIGHT:-40%} --min-height 20+ --bind=ctrl-z:ignore $1"
command cat "${FZF_DEFAULT_OPTS_FILE-}" 2> /dev/null
printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
builtin printf '%s\n' "${FZF_DEFAULT_OPTS-} $2"
}
__fzf_exec_awk() {

View File

@@ -323,7 +323,7 @@ func trySkip(input *util.Chars, caseSensitive bool, b byte, from int) int {
byteArray := input.Bytes()[from:]
// For case-insensitive search of a letter, search for both cases in one pass
if !caseSensitive && b >= 'a' && b <= 'z' {
idx := indexByteTwo(byteArray, b, b-32)
idx := IndexByteTwo(byteArray, b, b-32)
if idx < 0 {
return -1
}

View File

@@ -15,7 +15,7 @@ func cpuHasAVX2() bool
// or -1 if neither is present. Uses AVX2 when available, SSE2 otherwise.
//
//go:noescape
func indexByteTwo(s []byte, b1, b2 byte) int
func IndexByteTwo(s []byte, b1, b2 byte) int
// lastIndexByteTwo returns the index of the last occurrence of b1 or b2 in s,
// or -1 if neither is present. Uses AVX2 when available, SSE2 otherwise.

View File

@@ -41,11 +41,11 @@ cpuid_no:
MOVB $0, ret+0(FP)
RET
// func indexByteTwo(s []byte, b1, b2 byte) int
// func IndexByteTwo(s []byte, b1, b2 byte) int
//
// Returns the index of the first occurrence of b1 or b2 in s, or -1.
// Uses AVX2 (32 bytes/iter) when available, SSE2 (16 bytes/iter) otherwise.
TEXT ·indexByteTwo(SB),NOSPLIT,$0-40
TEXT ·IndexByteTwo(SB),NOSPLIT,$0-40
MOVQ s_base+0(FP), SI
MOVQ s_len+8(FP), BX
MOVBLZX b1+24(FP), AX

View File

@@ -7,7 +7,7 @@ package algo
// to search for both bytes in a single pass.
//
//go:noescape
func indexByteTwo(s []byte, b1, b2 byte) int
func IndexByteTwo(s []byte, b1, b2 byte) int
// lastIndexByteTwo returns the index of the last occurrence of b1 or b2 in s,
// or -1 if neither is present. Implemented in assembly using ARM64 NEON,

View File

@@ -1,11 +1,11 @@
#include "textflag.h"
// func indexByteTwo(s []byte, b1, b2 byte) int
// func IndexByteTwo(s []byte, b1, b2 byte) int
//
// Returns the index of the first occurrence of b1 or b2 in s, or -1.
// Uses ARM64 NEON to search for both bytes in a single pass over the data.
// Adapted from Go's internal/bytealg/indexbyte_arm64.s (single-byte version).
TEXT ·indexByteTwo(SB),NOSPLIT,$0-40
TEXT ·IndexByteTwo(SB),NOSPLIT,$0-40
MOVD s_base+0(FP), R0
MOVD s_len+8(FP), R2
MOVBU b1+24(FP), R1

View File

@@ -6,7 +6,7 @@ import "bytes"
// indexByteTwo returns the index of the first occurrence of b1 or b2 in s,
// or -1 if neither is present.
func indexByteTwo(s []byte, b1, b2 byte) int {
func IndexByteTwo(s []byte, b1, b2 byte) int {
i1 := bytes.IndexByte(s, b1)
if i1 == 0 {
return 0

View File

@@ -28,9 +28,9 @@ func TestIndexByteTwo(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := indexByteTwo([]byte(tt.s), tt.b1, tt.b2)
got := IndexByteTwo([]byte(tt.s), tt.b1, tt.b2)
if got != tt.want {
t.Errorf("indexByteTwo(%q, %c, %c) = %d, want %d", tt.s[:min(len(tt.s), 40)], tt.b1, tt.b2, got, tt.want)
t.Errorf("IndexByteTwo(%q, %c, %c) = %d, want %d", tt.s[:min(len(tt.s), 40)], tt.b1, tt.b2, got, tt.want)
}
})
}
@@ -46,27 +46,27 @@ func TestIndexByteTwo(t *testing.T) {
for pos := 0; pos < n; pos++ {
for _, b := range []byte{'A', 'B'} {
data[pos] = b
got := indexByteTwo(data, 'A', 'B')
got := IndexByteTwo(data, 'A', 'B')
want := loopIndexByteTwo(data, 'A', 'B')
if got != want {
t.Fatalf("indexByteTwo(len=%d, match=%c@%d) = %d, want %d", n, b, pos, got, want)
t.Fatalf("IndexByteTwo(len=%d, match=%c@%d) = %d, want %d", n, b, pos, got, want)
}
data[pos] = byte('c' + (pos % 20))
}
}
// Test with no match
got := indexByteTwo(data, 'A', 'B')
got := IndexByteTwo(data, 'A', 'B')
if got != -1 {
t.Fatalf("indexByteTwo(len=%d, no match) = %d, want -1", n, got)
t.Fatalf("IndexByteTwo(len=%d, no match) = %d, want -1", n, got)
}
// Test with both bytes present
if n >= 2 {
data[n/3] = 'A'
data[n*2/3] = 'B'
got := indexByteTwo(data, 'A', 'B')
got := IndexByteTwo(data, 'A', 'B')
want := loopIndexByteTwo(data, 'A', 'B')
if got != want {
t.Fatalf("indexByteTwo(len=%d, both@%d,%d) = %d, want %d", n, n/3, n*2/3, got, want)
t.Fatalf("IndexByteTwo(len=%d, both@%d,%d) = %d, want %d", n, n/3, n*2/3, got, want)
}
data[n/3] = byte('c' + ((n / 3) % 20))
data[n*2/3] = byte('c' + ((n * 2 / 3) % 20))
@@ -147,10 +147,10 @@ func FuzzIndexByteTwo(f *testing.F) {
f.Add([]byte(""), byte('a'), byte('b'))
f.Add([]byte("aaa"), byte('a'), byte('a'))
f.Fuzz(func(t *testing.T, data []byte, b1, b2 byte) {
got := indexByteTwo(data, b1, b2)
got := IndexByteTwo(data, b1, b2)
want := loopIndexByteTwo(data, b1, b2)
if got != want {
t.Errorf("indexByteTwo(len=%d, b1=%d, b2=%d) = %d, want %d", len(data), b1, b2, got, want)
t.Errorf("IndexByteTwo(len=%d, b1=%d, b2=%d) = %d, want %d", len(data), b1, b2, got, want)
}
})
}
@@ -214,7 +214,7 @@ func benchIndexByteTwo(b *testing.B, size int, pos int) {
fn func([]byte, byte, byte) int
}
impls := []impl{
{"asm", indexByteTwo},
{"asm", IndexByteTwo},
{"2xIndexByte", refIndexByteTwo},
{"loop", loopIndexByteTwo},
}

View File

@@ -6,6 +6,7 @@ import (
"strings"
"unicode/utf8"
"github.com/junegunn/fzf/src/algo"
"github.com/junegunn/fzf/src/tui"
)
@@ -123,31 +124,31 @@ func toAnsiString(color tui.Color, offset int) string {
return ret + ";"
}
func isPrint(c uint8) bool {
return '\x20' <= c && c <= '\x7e'
}
func matchOperatingSystemCommand(s string, start int) int {
// `\x1b][0-9][;:][[:print:]]+(?:\x1b\\\\|\x07)`
// ^ match starting here after the first printable character
//
i := start // prefix matched in nextAnsiEscapeSequence()
for ; i < len(s) && isPrint(s[i]); i++ {
// Find the terminator: BEL (\x07) or ESC (\x1b) for ST (\x1b\\)
idx := algo.IndexByteTwo(stringBytes(s[i:]), '\x07', '\x1b')
if idx < 0 {
return -1
}
if i < len(s) {
if s[i] == '\x07' {
return i + 1
}
// `\x1b]8;PARAMS;URI\x1b\\TITLE\x1b]8;;\x1b`
// ------
if s[i] == '\x1b' && i < len(s)-1 && s[i+1] == '\\' {
return i + 2
}
i += idx
if s[i] == '\x07' {
return i + 1
}
// `\x1b]8;PARAMS;URI\x1b\\TITLE\x1b]8;;\x1b`
// ------
if i < len(s)-1 && s[i+1] == '\\' {
return i + 2
}
// `\x1b]8;PARAMS;URI\x1b\\TITLE\x1b]8;;\x1b`
// ------------
if i < len(s) && s[:i+1] == "\x1b]8;;\x1b" {
if s[:i+1] == "\x1b]8;;\x1b" {
return i + 1
}
@@ -233,7 +234,7 @@ Loop:
// \x1b][0-9]+[;:][[:print:]]+(?:\x1b\\\\|\x07)
// ---------------
if j > 2 && i+j+1 < len(s) && (s[i+j] == ';' || s[i+j] == ':') && isPrint(s[i+j+1]) {
if j > 2 && i+j+1 < len(s) && (s[i+j] == ';' || s[i+j] == ':') && s[i+j+1] >= '\x20' {
if k := matchOperatingSystemCommand(s[i:], j+2); k != -1 {
return i, i + k
}

View File

@@ -2,10 +2,13 @@ package fzf
import "sync"
// queryCache associates strings to lists of items
type queryCache map[string][]Result
// ChunkBitmap is a bitmap with one bit per item in a chunk.
type ChunkBitmap [chunkBitWords]uint64
// ChunkCache associates Chunk and query string to lists of items
// queryCache associates query strings to bitmaps of matching items
type queryCache map[string]ChunkBitmap
// ChunkCache associates Chunk and query string to bitmaps
type ChunkCache struct {
mutex sync.Mutex
cache map[*Chunk]*queryCache
@@ -30,9 +33,9 @@ func (cc *ChunkCache) retire(chunk ...*Chunk) {
cc.mutex.Unlock()
}
// Add adds the list to the cache
func (cc *ChunkCache) Add(chunk *Chunk, key string, list []Result) {
if len(key) == 0 || !chunk.IsFull() || len(list) > queryCacheMax {
// Add stores the bitmap for the given chunk and key
func (cc *ChunkCache) Add(chunk *Chunk, key string, bitmap ChunkBitmap, matchCount int) {
if len(key) == 0 || !chunk.IsFull() || matchCount > queryCacheMax {
return
}
@@ -44,11 +47,11 @@ func (cc *ChunkCache) Add(chunk *Chunk, key string, list []Result) {
cc.cache[chunk] = &queryCache{}
qc = cc.cache[chunk]
}
(*qc)[key] = list
(*qc)[key] = bitmap
}
// Lookup is called to lookup ChunkCache
func (cc *ChunkCache) Lookup(chunk *Chunk, key string) []Result {
// Lookup returns the bitmap for the exact key
func (cc *ChunkCache) Lookup(chunk *Chunk, key string) *ChunkBitmap {
if len(key) == 0 || !chunk.IsFull() {
return nil
}
@@ -58,15 +61,15 @@ func (cc *ChunkCache) Lookup(chunk *Chunk, key string) []Result {
qc, ok := cc.cache[chunk]
if ok {
list, ok := (*qc)[key]
if ok {
return list
if bm, ok := (*qc)[key]; ok {
return &bm
}
}
return nil
}
func (cc *ChunkCache) Search(chunk *Chunk, key string) []Result {
// Search finds the bitmap for the longest prefix or suffix of the key
func (cc *ChunkCache) Search(chunk *Chunk, key string) *ChunkBitmap {
if len(key) == 0 || !chunk.IsFull() {
return nil
}
@@ -86,8 +89,8 @@ func (cc *ChunkCache) Search(chunk *Chunk, key string) []Result {
prefix := key[:len(key)-idx]
suffix := key[idx:]
for _, substr := range [2]string{prefix, suffix} {
if cached, found := (*qc)[substr]; found {
return cached
if bm, found := (*qc)[substr]; found {
return &bm
}
}
}

View File

@@ -6,34 +6,34 @@ func TestChunkCache(t *testing.T) {
cache := NewChunkCache()
chunk1p := &Chunk{}
chunk2p := &Chunk{count: chunkSize}
items1 := []Result{{}}
items2 := []Result{{}, {}}
cache.Add(chunk1p, "foo", items1)
cache.Add(chunk2p, "foo", items1)
cache.Add(chunk2p, "bar", items2)
bm1 := ChunkBitmap{1}
bm2 := ChunkBitmap{1, 2}
cache.Add(chunk1p, "foo", bm1, 1)
cache.Add(chunk2p, "foo", bm1, 1)
cache.Add(chunk2p, "bar", bm2, 2)
{ // chunk1 is not full
cached := cache.Lookup(chunk1p, "foo")
if cached != nil {
t.Error("Cached disabled for non-empty chunks", cached)
t.Error("Cached disabled for non-full chunks", cached)
}
}
{
cached := cache.Lookup(chunk2p, "foo")
if cached == nil || len(cached) != 1 {
t.Error("Expected 1 item cached", cached)
if cached == nil || cached[0] != 1 {
t.Error("Expected bitmap cached", cached)
}
}
{
cached := cache.Lookup(chunk2p, "bar")
if cached == nil || len(cached) != 2 {
t.Error("Expected 2 items cached", cached)
if cached == nil || cached[1] != 2 {
t.Error("Expected bitmap cached", cached)
}
}
{
cached := cache.Lookup(chunk1p, "foobar")
if cached != nil {
t.Error("Expected 0 item cached", cached)
t.Error("Expected nil cached", cached)
}
}
}

View File

@@ -34,19 +34,18 @@ const (
maxBgProcessesPerAction = 3
// Matcher
numPartitionsMultiplier = 8
maxPartitions = 32
progressMinDuration = 200 * time.Millisecond
progressMinDuration = 200 * time.Millisecond
// Capacity of each chunk
chunkSize int = 1000
chunkSize int = 1024
chunkBitWords = (chunkSize + 63) / 64
// Pre-allocated memory slices to minimize GC
slab16Size int = 100 * 1024 // 200KB * 32 = 12.8MB
slab32Size int = 2048 // 8KB * 32 = 256KB
// Do not cache results of low selectivity queries
queryCacheMax int = chunkSize / 5
queryCacheMax int = chunkSize / 2
// Not to cache mergers with large lists
mergerCacheMax int = 100000

View File

@@ -195,11 +195,13 @@ func Run(opts *Options) (int, error) {
// Reader
streamingFilter := opts.Filter != nil && !sort && !opts.Tac && !opts.Sync && opts.Bench == 0
var reader *Reader
var ingestionStart time.Time
if !streamingFilter {
reader = NewReader(func(data []byte) bool {
return chunkList.Push(data)
}, eventBox, executor, opts.ReadZero, opts.Filter == nil)
ingestionStart = time.Now()
readyChan := make(chan bool)
go reader.ReadSource(opts.Input, opts.WalkerRoot, opts.WalkerOpts, opts.WalkerSkip, initialReload, initialEnv, readyChan)
<-readyChan
@@ -283,6 +285,7 @@ func Run(opts *Options) (int, error) {
} else {
eventBox.Unwatch(EvtReadNew)
eventBox.WaitFor(EvtReadFin)
ingestionTime := time.Since(ingestionStart)
// NOTE: Streaming filter is inherently not compatible with --tail
snapshot, _, _ := chunkList.Snapshot(opts.Tail)
@@ -316,13 +319,14 @@ func Run(opts *Options) (int, error) {
}
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",
fmt.Printf(" %d iterations avg: %.2fms min: %.2fms max: %.2fms total: %.2fs items: %d matches: %d (%.2f%%) ingestion: %.2fms\n",
len(times),
float64(avg.Microseconds())/1000,
float64(minD.Microseconds())/1000,
float64(maxD.Microseconds())/1000,
total.Seconds(),
totalItems, matchCount, selectivity)
totalItems, matchCount, selectivity,
float64(ingestionTime.Microseconds())/1000)
return ExitOk, nil
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"runtime"
"sync"
"sync/atomic"
"time"
"github.com/junegunn/fzf/src/util"
@@ -57,7 +58,7 @@ const (
// NewMatcher returns a new Matcher
func NewMatcher(cache *ChunkCache, patternBuilder func([]rune) *Pattern,
sort bool, tac bool, eventBox *util.EventBox, revision revision, threads int) *Matcher {
partitions := min(numPartitionsMultiplier*runtime.NumCPU(), maxPartitions)
partitions := runtime.NumCPU()
if threads > 0 {
partitions = threads
}
@@ -148,27 +149,6 @@ func (m *Matcher) Loop() {
}
}
func (m *Matcher) sliceChunks(chunks []*Chunk) [][]*Chunk {
partitions := m.partitions
perSlice := len(chunks) / partitions
if perSlice == 0 {
partitions = len(chunks)
perSlice = 1
}
slices := make([][]*Chunk, partitions)
for i := 0; i < partitions; i++ {
start := i * perSlice
end := start + perSlice
if i == partitions-1 {
end = len(chunks)
}
slices[i] = chunks[start:end]
}
return slices
}
type partialResult struct {
index int
matches []Result
@@ -192,39 +172,37 @@ func (m *Matcher) scan(request MatchRequest) MatchResult {
maxIndex := request.chunks[numChunks-1].lastIndex(minIndex)
cancelled := util.NewAtomicBool(false)
slices := m.sliceChunks(request.chunks)
numSlices := len(slices)
resultChan := make(chan partialResult, numSlices)
numWorkers := min(m.partitions, numChunks)
var nextChunk atomic.Int32
resultChan := make(chan partialResult, numWorkers)
countChan := make(chan int, numChunks)
waitGroup := sync.WaitGroup{}
for idx, chunks := range slices {
for idx := range numWorkers {
waitGroup.Add(1)
if m.slab[idx] == nil {
m.slab[idx] = util.MakeSlab(slab16Size, slab32Size)
}
go func(idx int, slab *util.Slab, chunks []*Chunk) {
defer func() { waitGroup.Done() }()
count := 0
allMatches := make([][]Result, len(chunks))
for idx, chunk := range chunks {
matches := request.pattern.Match(chunk, slab)
allMatches[idx] = matches
count += len(matches)
go func(idx int, slab *util.Slab) {
defer waitGroup.Done()
var matches []Result
for {
ci := int(nextChunk.Add(1)) - 1
if ci >= numChunks {
break
}
chunkMatches := request.pattern.Match(request.chunks[ci], slab)
matches = append(matches, chunkMatches...)
if cancelled.Get() {
return
}
countChan <- len(matches)
}
sliceMatches := make([]Result, 0, count)
for _, matches := range allMatches {
sliceMatches = append(sliceMatches, matches...)
countChan <- len(chunkMatches)
}
if m.sort && request.pattern.sortable {
m.sortBuf[idx] = radixSortResults(sliceMatches, m.tac, m.sortBuf[idx])
m.sortBuf[idx] = radixSortResults(matches, m.tac, m.sortBuf[idx])
}
resultChan <- partialResult{idx, sliceMatches}
}(idx, m.slab[idx], chunks)
resultChan <- partialResult{idx, matches}
}(idx, m.slab[idx])
}
wait := func() bool {
@@ -252,8 +230,8 @@ func (m *Matcher) scan(request MatchRequest) MatchResult {
}
}
partialResults := make([][]Result, numSlices)
for range slices {
partialResults := make([][]Result, numWorkers)
for range numWorkers {
partialResult := <-resultChan
partialResults[partialResult.index] = partialResult.matches
}

View File

@@ -61,7 +61,7 @@ type Pattern struct {
delimiter Delimiter
nth []Range
revision revision
procFun map[termType]algo.Algo
procFun [6]algo.Algo
cache *ChunkCache
denylist map[int32]struct{}
startIndex int32
@@ -150,7 +150,7 @@ func BuildPattern(cache *ChunkCache, patternCache map[string]*Pattern, fuzzy boo
cache: cache,
denylist: denylist,
startIndex: startIndex,
procFun: make(map[termType]algo.Algo)}
}
ptr.cacheKey = ptr.buildCacheKey()
ptr.directAlgo, ptr.directTerm = ptr.buildDirectAlgo(fuzzyAlgo)
@@ -300,104 +300,87 @@ func (p *Pattern) CacheKey() string {
// Match returns the list of matches Items in the given Chunk
func (p *Pattern) Match(chunk *Chunk, slab *util.Slab) []Result {
// ChunkCache: Exact match
cacheKey := p.CacheKey()
// Bitmap cache: exact match or prefix/suffix
var cachedBitmap *ChunkBitmap
if p.cacheable {
if cached := p.cache.Lookup(chunk, cacheKey); cached != nil {
return cached
}
cachedBitmap = p.cache.Lookup(chunk, cacheKey)
}
if cachedBitmap == nil {
cachedBitmap = p.cache.Search(chunk, cacheKey)
}
// Prefix/suffix cache
space := p.cache.Search(chunk, cacheKey)
matches := p.matchChunk(chunk, space, slab)
matches, bitmap := p.matchChunk(chunk, cachedBitmap, slab)
if p.cacheable {
p.cache.Add(chunk, cacheKey, matches)
p.cache.Add(chunk, cacheKey, bitmap, len(matches))
}
return matches
}
func (p *Pattern) matchChunk(chunk *Chunk, space []Result, slab *util.Slab) []Result {
func (p *Pattern) matchChunk(chunk *Chunk, cachedBitmap *ChunkBitmap, slab *util.Slab) ([]Result, ChunkBitmap) {
matches := []Result{}
var bitmap ChunkBitmap
// Skip header items in chunks that contain them
startIdx := 0
if p.startIndex > 0 && chunk.count > 0 && chunk.items[0].Index() < p.startIndex {
startIdx = int(p.startIndex - chunk.items[0].Index())
if startIdx >= chunk.count {
return matches
return matches, bitmap
}
}
hasCachedBitmap := cachedBitmap != nil
// Fast path: single fuzzy term, no nth, no denylist.
// Calls the algo function directly, bypassing MatchItem/extendedMatch/iter
// and avoiding per-match []Offset heap allocation.
if p.directAlgo != nil && len(p.denylist) == 0 {
t := p.directTerm
if space == nil {
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))
}
for idx := startIdx; idx < chunk.count; idx++ {
if hasCachedBitmap && cachedBitmap[idx/64]&(uint64(1)<<(idx%64)) == 0 {
continue
}
} else {
for _, result := range space {
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))
}
res, _ := p.directAlgo(t.caseSensitive, t.normalize, p.forward,
&chunk.items[idx].text, t.text, p.withPos, slab)
if res.Start >= 0 {
bitmap[idx/64] |= uint64(1) << (idx % 64)
matches = append(matches, buildResultFromBounds(
&chunk.items[idx], res.Score,
int(res.Start), int(res.End), int(res.End), true))
}
}
return matches
return matches, bitmap
}
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)
}
}
}
return matches
}
if space == nil {
for idx := startIdx; idx < chunk.count; idx++ {
if _, prs := p.denylist[chunk.items[idx].Index()]; prs {
if hasCachedBitmap && cachedBitmap[idx/64]&(uint64(1)<<(idx%64)) == 0 {
continue
}
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match.item != nil {
bitmap[idx/64] |= uint64(1) << (idx % 64)
matches = append(matches, match)
}
}
} else {
for _, result := range space {
if _, prs := p.denylist[result.item.Index()]; prs {
continue
}
if match, _, _ := p.MatchItem(result.item, p.withPos, slab); match.item != nil {
matches = append(matches, match)
}
return matches, bitmap
}
for idx := startIdx; idx < chunk.count; idx++ {
if hasCachedBitmap && cachedBitmap[idx/64]&(uint64(1)<<(idx%64)) == 0 {
continue
}
if _, prs := p.denylist[chunk.items[idx].Index()]; prs {
continue
}
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match.item != nil {
bitmap[idx/64] |= uint64(1) << (idx % 64)
matches = append(matches, match)
}
}
return matches
return matches, bitmap
}
// MatchItem returns the match result if the Item is a match.

View File

@@ -2,6 +2,7 @@ package fzf
import (
"reflect"
"runtime"
"testing"
"github.com/junegunn/fzf/src/algo"
@@ -137,7 +138,7 @@ func TestOrigTextAndTransformed(t *testing.T) {
origText: &origBytes,
transformed: &transformed{pattern.revision, trans}}
pattern.extended = extended
matches := pattern.matchChunk(&chunk, nil, slab) // No cache
matches, _ := pattern.matchChunk(&chunk, nil, slab) // No cache
if !(matches[0].item.text.ToString() == "junegunn" &&
string(*matches[0].item.origText) == "junegunn.choi" &&
reflect.DeepEqual((*matches[0].item.transformed).tokens, trans)) {
@@ -199,3 +200,119 @@ func TestCacheable(t *testing.T) {
test(false, "foo 'bar", "foo", false)
test(false, "foo !bar", "foo", false)
}
func buildChunks(numChunks int) []*Chunk {
chunks := make([]*Chunk, numChunks)
words := []string{
"src/main/java/com/example/service/UserService.java",
"src/test/java/com/example/service/UserServiceTest.java",
"docs/api/reference/endpoints.md",
"lib/internal/utils/string_helper.go",
"pkg/server/http/handler/auth.go",
"build/output/release/app.exe",
"config/production/database.yml",
"scripts/deploy/kubernetes/setup.sh",
"vendor/github.com/junegunn/fzf/src/core.go",
"node_modules/.cache/babel/transform.js",
}
for ci := range numChunks {
chunks[ci] = &Chunk{count: chunkSize}
for i := range chunkSize {
text := words[(ci*chunkSize+i)%len(words)]
chunks[ci].items[i] = Item{text: util.ToChars([]byte(text))}
chunks[ci].items[i].text.Index = int32(ci*chunkSize + i)
}
}
return chunks
}
func buildPatternWith(cache *ChunkCache, runes []rune) *Pattern {
return BuildPattern(cache, make(map[string]*Pattern),
true, algo.FuzzyMatchV2, true, CaseSmart, false, true,
false, true, []Range{}, Delimiter{}, revision{}, runes, nil, 0)
}
func TestBitmapCacheBenefit(t *testing.T) {
numChunks := 100
chunks := buildChunks(numChunks)
queries := []string{"s", "se", "ser", "serv", "servi"}
// 1. Run all queries with shared cache (simulates incremental typing)
cache := NewChunkCache()
for _, q := range queries {
pat := buildPatternWith(cache, []rune(q))
for _, chunk := range chunks {
pat.Match(chunk, slab)
}
}
// 2. GC and measure memory with cache populated
runtime.GC()
runtime.GC()
var memWith runtime.MemStats
runtime.ReadMemStats(&memWith)
// 3. Clear cache, GC, measure again
cache.Clear()
runtime.GC()
runtime.GC()
var memWithout runtime.MemStats
runtime.ReadMemStats(&memWithout)
cacheMem := int64(memWith.Alloc) - int64(memWithout.Alloc)
t.Logf("Chunks: %d, Queries: %d", numChunks, len(queries))
t.Logf("Cache memory: %d bytes (%.1f KB)", cacheMem, float64(cacheMem)/1024)
t.Logf("Per-chunk-per-query: %.0f bytes", float64(cacheMem)/float64(numChunks*len(queries)))
// 4. Verify correctness: cached vs uncached produce same results
cache2 := NewChunkCache()
for _, q := range queries {
pat := buildPatternWith(cache2, []rune(q))
for _, chunk := range chunks {
pat.Match(chunk, slab)
}
}
for _, q := range queries {
patCached := buildPatternWith(cache2, []rune(q))
patFresh := buildPatternWith(NewChunkCache(), []rune(q))
var countCached, countFresh int
for _, chunk := range chunks {
countCached += len(patCached.Match(chunk, slab))
countFresh += len(patFresh.Match(chunk, slab))
}
if countCached != countFresh {
t.Errorf("query=%q: cached=%d, fresh=%d", q, countCached, countFresh)
}
t.Logf("query=%q: matches=%d", q, countCached)
}
}
func BenchmarkWithCache(b *testing.B) {
numChunks := 100
chunks := buildChunks(numChunks)
queries := []string{"s", "se", "ser", "serv", "servi"}
b.Run("cached", func(b *testing.B) {
for range b.N {
cache := NewChunkCache()
for _, q := range queries {
pat := buildPatternWith(cache, []rune(q))
for _, chunk := range chunks {
pat.Match(chunk, slab)
}
}
}
})
b.Run("uncached", func(b *testing.B) {
for range b.N {
for _, q := range queries {
cache := NewChunkCache()
pat := buildPatternWith(cache, []rune(q))
for _, chunk := range chunks {
pat.Match(chunk, slab)
}
}
}
})
}

View File

@@ -6,5 +6,5 @@ import "golang.org/x/sys/unix"
// Protect calls OS specific protections like pledge on OpenBSD
func Protect() {
unix.PledgePromises("stdio dpath wpath rpath tty proc exec inet tmppath")
unix.PledgePromises("stdio cpath dpath wpath rpath tty proc exec inet")
}

View File

@@ -3905,6 +3905,7 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
frozenRight = line[splitOffsetRight:]
}
displayWidthSum := 0
displayWidthLeft := 0
todo := [3]func(){}
for fidx, runes := range [][]rune{frozenLeft, frozenRight, middle} {
if len(runes) == 0 {
@@ -3930,7 +3931,11 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
// For frozen parts, reserve space for the ellipsis in the middle part
adjustedMaxWidth -= ellipsisWidth
}
displayWidth = t.displayWidthWithLimit(runes, 0, adjustedMaxWidth)
var prefixWidth int
if fidx == 2 {
prefixWidth = displayWidthLeft
}
displayWidth = t.displayWidthWithLimit(runes, prefixWidth, adjustedMaxWidth)
if !t.wrap && displayWidth > adjustedMaxWidth {
maxe = util.Constrain(maxe+min(maxWidth/2-ellipsisWidth, t.hscrollOff), 0, len(runes))
transformOffsets := func(diff int32) {
@@ -3968,6 +3973,9 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
displayWidth = t.displayWidthWithLimit(runes, 0, maxWidth)
}
displayWidthSum += displayWidth
if fidx == 0 {
displayWidthLeft = displayWidth
}
if maxWidth > 0 {
color := colBase
@@ -3975,7 +3983,7 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
color = color.WithFg(t.theme.Nomatch)
}
todo[fidx] = func() {
t.printColoredString(t.window, runes, offs, color)
t.printColoredString(t.window, runes, offs, color, prefixWidth)
}
} else {
break
@@ -4002,10 +4010,13 @@ func (t *Terminal) printHighlighted(result Result, colBase tui.ColorPair, colMat
return finalLineNum
}
func (t *Terminal) printColoredString(window tui.Window, text []rune, offsets []colorOffset, colBase tui.ColorPair) {
func (t *Terminal) printColoredString(window tui.Window, text []rune, offsets []colorOffset, colBase tui.ColorPair, initialPrefixWidth ...int) {
var index int32
var substr string
var prefixWidth int
if len(initialPrefixWidth) > 0 {
prefixWidth = initialPrefixWidth[0]
}
maxOffset := int32(len(text))
var url *url
for _, offset := range offsets {
@@ -4212,7 +4223,7 @@ func (t *Terminal) followOffset() int {
for i := len(body) - 1; i >= 0; i-- {
h := t.previewLineHeight(body[i], maxWidth)
if visualLines+h > height {
return headerLines + i + 1
return min(len(lines)-1, headerLines+i+1)
}
visualLines += h
}
@@ -4510,7 +4521,7 @@ Loop:
}
}
t.previewer.scrollable = t.previewer.scrollable || t.pwindow.Y() == height-1 && t.pwindow.X() == t.pwindow.Width()
t.previewer.scrollable = t.previewer.scrollable || t.pwindow.Y() == height-1 && t.pwindow.X() == t.pwindow.Width() || t.previewed.filled
if fillRet == tui.FillNextLine {
continue
} else if fillRet == tui.FillSuspend {
@@ -4533,7 +4544,7 @@ Loop:
}
lineNo++
}
t.previewer.scrollable = t.previewer.scrollable || index < len(lines)-1
t.previewer.scrollable = t.previewer.scrollable || t.previewed.filled || index < len(lines)-1
t.previewed.image = image
t.previewed.wireframe = wireframe
}

View File

@@ -161,7 +161,7 @@ func awkTokenizer(input string) ([]string, int) {
end := 0
for idx := 0; idx < len(input); idx++ {
r := input[idx]
white := r == 9 || r == 32
white := r == 9 || r == 32 || r == 10
switch state {
case awkNil:
if white {
@@ -218,11 +218,12 @@ func Tokenize(text string, delimiter Delimiter) []Token {
return withPrefixLengths(tokens, 0)
}
// StripLastDelimiter removes the trailing delimiter and whitespaces
// StripLastDelimiter removes the trailing delimiter
func StripLastDelimiter(str string, delimiter Delimiter) string {
if delimiter.str != nil {
str = strings.TrimSuffix(str, *delimiter.str)
} else if delimiter.regex != nil {
return strings.TrimSuffix(str, *delimiter.str)
}
if delimiter.regex != nil {
locs := delimiter.regex.FindAllStringIndex(str, -1)
if len(locs) > 0 {
lastLoc := locs[len(locs)-1]
@@ -230,6 +231,7 @@ func StripLastDelimiter(str string, delimiter Delimiter) string {
str = str[:lastLoc[0]]
}
}
return str
}
return strings.TrimRightFunc(str, unicode.IsSpace)
}

View File

@@ -56,9 +56,9 @@ func TestParseRange(t *testing.T) {
func TestTokenize(t *testing.T) {
// AWK-style
input := " abc: def: ghi "
input := " abc: \n\t def: ghi "
tokens := Tokenize(input, Delimiter{})
if tokens[0].text.ToString() != "abc: " || tokens[0].prefixLength != 2 {
if tokens[0].text.ToString() != "abc: \n\t " || tokens[0].prefixLength != 2 {
t.Errorf("%s", tokens)
}
@@ -71,9 +71,9 @@ func TestTokenize(t *testing.T) {
// With delimiter regex
tokens = Tokenize(input, delimiterRegexp("\\s+"))
if tokens[0].text.ToString() != " " || tokens[0].prefixLength != 0 ||
tokens[1].text.ToString() != "abc: " || tokens[1].prefixLength != 2 ||
tokens[2].text.ToString() != "def: " || tokens[2].prefixLength != 8 ||
tokens[3].text.ToString() != "ghi " || tokens[3].prefixLength != 14 {
tokens[1].text.ToString() != "abc: \n\t " || tokens[1].prefixLength != 2 ||
tokens[2].text.ToString() != "def: " || tokens[2].prefixLength != 10 ||
tokens[3].text.ToString() != "ghi " || tokens[3].prefixLength != 16 {
t.Errorf("%s", tokens)
}
}

View File

@@ -9,6 +9,7 @@ import (
"strings"
"syscall"
"github.com/junegunn/go-shellwords"
"golang.org/x/sys/unix"
)
@@ -20,8 +21,8 @@ type Executor struct {
func NewExecutor(withShell string) *Executor {
shell := os.Getenv("SHELL")
args := strings.Fields(withShell)
if len(args) > 0 {
args, err := shellwords.Parse(withShell)
if err == nil && len(args) > 0 {
shell = args[0]
args = args[1:]
} else {

View File

@@ -1190,6 +1190,16 @@ class TestCore < TestInteractive
tmux.until { |lines| assert lines.any_include?('9999␊10000') }
end
def test_freeze_left_tabstop
writelines(%W[1\t2\t3])
# With --freeze-left 1 and --tabstop=2:
# Frozen left: "1" (width 1)
# Middle starts with "\t" at prefix width 1, tabstop 2 → 1 space
# Then "2" at column 2, next "\t" at column 3 → 1 space, then "3"
tmux.send_keys %(cat #{tempname} | #{FZF} --tabstop=2 --freeze-left 1), :Enter
tmux.until { |lines| assert_equal '> 1 2 3', lines[-3] }
end
def test_freeze_left_keep_right
tmux.send_keys %(seq 10000 | #{FZF} --read0 --delimiter "\n" --freeze-left 3 --keep-right --ellipsis XX --no-multi-line --bind space:toggle-multi-line), :Enter
tmux.until { |lines| assert_match(/^> 1␊2␊3XX.*10000␊$/, lines[-3]) }
@@ -2085,13 +2095,13 @@ class TestCore < TestInteractive
tmux.send_keys %(echo "foo ,bar,baz" | #{FZF} -d, --accept-nth 2,2,1,3,1 --sync --bind start:accept > #{tempname}), :Enter
wait do
assert_path_exists tempname
# Last delimiter and the whitespaces are removed
assert_equal ['bar,bar,foo ,bazfoo'], File.readlines(tempname, chomp: true)
# Last delimiter is removed
assert_equal ['bar,bar,foo ,bazfoo '], File.readlines(tempname, chomp: true)
end
end
def test_accept_nth_regex_delimiter
tmux.send_keys %(echo "foo :,:bar,baz" | #{FZF} --delimiter='[:,]+' --accept-nth 2,2,1,3,1 --sync --bind start:accept > #{tempname}), :Enter
tmux.send_keys %(echo "foo :,:bar,baz" | #{FZF} --delimiter=' *[:,]+ *' --accept-nth 2,2,1,3,1 --sync --bind start:accept > #{tempname}), :Enter
wait do
assert_path_exists tempname
# Last delimiter and the whitespaces are removed
@@ -2109,7 +2119,7 @@ class TestCore < TestInteractive
end
def test_accept_nth_template
tmux.send_keys %(echo "foo ,bar,baz" | #{FZF} -d, --accept-nth '[{n}] 1st: {1}, 3rd: {3}, 2nd: {2}' --sync --bind start:accept > #{tempname}), :Enter
tmux.send_keys %(echo "foo ,bar,baz" | #{FZF} -d " *, *" --accept-nth '[{n}] 1st: {1}, 3rd: {3}, 2nd: {2}' --sync --bind start:accept > #{tempname}), :Enter
wait do
assert_path_exists tempname
# Last delimiter and the whitespaces are removed

View File

@@ -393,6 +393,20 @@ class TestPreview < TestInteractive
end
end
def test_preview_follow_wrap_long_line
tmux.send_keys %(seq 1 | #{FZF} --preview "seq 2; yes yes | head -10000 | tr '\n' ' '" --preview-window follow,wrap --bind up:preview-up,down:preview-down), :Enter
tmux.until do |lines|
assert_equal 1, lines.match_count
assert lines.any_include?('3/3 │')
end
tmux.send_keys :Up
tmux.until { |lines| assert lines.any_include?('2/3 │') }
tmux.send_keys :Up
tmux.until { |lines| assert lines.any_include?('1/3 │') }
tmux.send_keys :Down
tmux.until { |lines| assert lines.any_include?('2/3 │') }
end
def test_close
tmux.send_keys "seq 100 | #{FZF} --preview 'echo foo' --bind ctrl-c:close", :Enter
tmux.until { |lines| assert_equal 100, lines.match_count }
@@ -593,7 +607,7 @@ class TestPreview < TestInteractive
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.send_keys %(seq 1 | #{FZF} --preview 'echo -e "\\x1b[33m123 \\x1b[mhi"; echo -e "\\x1b[33m123 \\x1b[mhi"' --preview-window 2,wrap-word,noinfo), :Enter
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_equal(2, lines.count { |line| line.include?('│ 12 │') })
@@ -602,7 +616,7 @@ class TestPreview < TestInteractive
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.send_keys %(seq 1 | #{FZF} --preview 'echo -e "\\x1b[33m123 \\x1b[mhi"; echo -e "\\x1b[33m123 \\x1b[mhi"' --preview-window 1,wrap-word,noinfo), :Enter
tmux.until do |lines|
assert_equal 1, lines.match_count
assert_equal(2, lines.count { |line| line.include?('│ 1 │') })

View File

@@ -7,4 +7,4 @@ tabe = "tabe"
Iterm = "Iterm"
[files]
extend-exclude = ["README.md"]
extend-exclude = ["README.md", "*.s"]