Compare commits

..

18 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
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-01 11:16:52 +09:00
junegunn
90c4269d4e Deploying to master from @ junegunn/fzf@6087055305 🚀
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-02-28 15:01:49 +00:00
Junegunn Choi
6087055305 Enable uint64 compareRanks on arm64
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
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
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
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
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
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
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
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
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
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 🚀
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-02-21 15:03:44 +00:00
20 changed files with 794 additions and 264 deletions

64
.github/labeler.yml vendored Normal file
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
.github/workflows/labeler.yml vendored Normal file
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

File diff suppressed because one or more lines are too long

View File

@@ -1881,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\-border\-label(...)\fR (change \fB\-\-border\-label\fR to the given string)
\fBchange\-ghost(...)\fR (change ghost text 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(...)\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\-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\-input\-label(...)\fR (change \fB\-\-input\-label\fR to the given string)
\fBchange\-list\-label(...)\fR (change \fB\-\-list\-label\fR to the given string) \fBchange\-list\-label(...)\fR (change \fB\-\-list\-label\fR to the given string)
@@ -1987,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\-border\-label(...)\fR (transform border label using an external command)
\fBtransform\-ghost(...)\fR (transform ghost text 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(...)\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\-header\-label(...)\fR (transform header label using an external command)
\fBtransform\-input\-label(...)\fR (transform input label using an external command) \fBtransform\-input\-label(...)\fR (transform input label using an external command)
\fBtransform\-list\-label(...)\fR (transform list label using an external command) \fBtransform\-list\-label(...)\fR (transform list label using an external command)

View File

@@ -30,161 +30,164 @@ func _() {
_ = x[actChangeBorderLabel-19] _ = x[actChangeBorderLabel-19]
_ = x[actChangeGhost-20] _ = x[actChangeGhost-20]
_ = x[actChangeHeader-21] _ = x[actChangeHeader-21]
_ = x[actChangeFooter-22] _ = x[actChangeHeaderLines-22]
_ = x[actChangeHeaderLabel-23] _ = x[actChangeFooter-23]
_ = x[actChangeFooterLabel-24] _ = x[actChangeHeaderLabel-24]
_ = x[actChangeInputLabel-25] _ = x[actChangeFooterLabel-25]
_ = x[actChangeListLabel-26] _ = x[actChangeInputLabel-26]
_ = x[actChangeMulti-27] _ = x[actChangeListLabel-27]
_ = x[actChangeNth-28] _ = x[actChangeMulti-28]
_ = x[actChangePointer-29] _ = x[actChangeNth-29]
_ = x[actChangePreview-30] _ = x[actChangePointer-30]
_ = x[actChangePreviewLabel-31] _ = x[actChangePreview-31]
_ = x[actChangePreviewWindow-32] _ = x[actChangePreviewLabel-32]
_ = x[actChangePrompt-33] _ = x[actChangePreviewWindow-33]
_ = x[actChangeQuery-34] _ = x[actChangePrompt-34]
_ = x[actClearScreen-35] _ = x[actChangeQuery-35]
_ = x[actClearQuery-36] _ = x[actClearScreen-36]
_ = x[actClearSelection-37] _ = x[actClearQuery-37]
_ = x[actClose-38] _ = x[actClearSelection-38]
_ = x[actDeleteChar-39] _ = x[actClose-39]
_ = x[actDeleteCharEof-40] _ = x[actDeleteChar-40]
_ = x[actEndOfLine-41] _ = x[actDeleteCharEof-41]
_ = x[actFatal-42] _ = x[actEndOfLine-42]
_ = x[actForwardChar-43] _ = x[actFatal-43]
_ = x[actForwardWord-44] _ = x[actForwardChar-44]
_ = x[actForwardSubWord-45] _ = x[actForwardWord-45]
_ = x[actKillLine-46] _ = x[actForwardSubWord-46]
_ = x[actKillWord-47] _ = x[actKillLine-47]
_ = x[actKillSubWord-48] _ = x[actKillWord-48]
_ = x[actUnixLineDiscard-49] _ = x[actKillSubWord-49]
_ = x[actUnixWordRubout-50] _ = x[actUnixLineDiscard-50]
_ = x[actYank-51] _ = x[actUnixWordRubout-51]
_ = x[actBackwardKillWord-52] _ = x[actYank-52]
_ = x[actBackwardKillSubWord-53] _ = x[actBackwardKillWord-53]
_ = x[actSelectAll-54] _ = x[actBackwardKillSubWord-54]
_ = x[actDeselectAll-55] _ = x[actSelectAll-55]
_ = x[actToggle-56] _ = x[actDeselectAll-56]
_ = x[actToggleSearch-57] _ = x[actToggle-57]
_ = x[actToggleAll-58] _ = x[actToggleSearch-58]
_ = x[actToggleDown-59] _ = x[actToggleAll-59]
_ = x[actToggleUp-60] _ = x[actToggleDown-60]
_ = x[actToggleIn-61] _ = x[actToggleUp-61]
_ = x[actToggleOut-62] _ = x[actToggleIn-62]
_ = x[actToggleTrack-63] _ = x[actToggleOut-63]
_ = x[actToggleTrackCurrent-64] _ = x[actToggleTrack-64]
_ = x[actToggleHeader-65] _ = x[actToggleTrackCurrent-65]
_ = x[actToggleWrap-66] _ = x[actToggleHeader-66]
_ = x[actToggleWrapWord-67] _ = x[actToggleWrap-67]
_ = x[actToggleMultiLine-68] _ = x[actToggleWrapWord-68]
_ = x[actToggleHscroll-69] _ = x[actToggleMultiLine-69]
_ = x[actToggleRaw-70] _ = x[actToggleHscroll-70]
_ = x[actEnableRaw-71] _ = x[actToggleRaw-71]
_ = x[actDisableRaw-72] _ = x[actEnableRaw-72]
_ = x[actTrackCurrent-73] _ = x[actDisableRaw-73]
_ = x[actToggleInput-74] _ = x[actTrackCurrent-74]
_ = x[actHideInput-75] _ = x[actToggleInput-75]
_ = x[actShowInput-76] _ = x[actHideInput-76]
_ = x[actUntrackCurrent-77] _ = x[actShowInput-77]
_ = x[actDown-78] _ = x[actUntrackCurrent-78]
_ = x[actDownMatch-79] _ = x[actDown-79]
_ = x[actUp-80] _ = x[actDownMatch-80]
_ = x[actUpMatch-81] _ = x[actUp-81]
_ = x[actPageUp-82] _ = x[actUpMatch-82]
_ = x[actPageDown-83] _ = x[actPageUp-83]
_ = x[actPosition-84] _ = x[actPageDown-84]
_ = x[actHalfPageUp-85] _ = x[actPosition-85]
_ = x[actHalfPageDown-86] _ = x[actHalfPageUp-86]
_ = x[actOffsetUp-87] _ = x[actHalfPageDown-87]
_ = x[actOffsetDown-88] _ = x[actOffsetUp-88]
_ = x[actOffsetMiddle-89] _ = x[actOffsetDown-89]
_ = x[actJump-90] _ = x[actOffsetMiddle-90]
_ = x[actJumpAccept-91] _ = x[actJump-91]
_ = x[actPrintQuery-92] _ = x[actJumpAccept-92]
_ = x[actRefreshPreview-93] _ = x[actPrintQuery-93]
_ = x[actReplaceQuery-94] _ = x[actRefreshPreview-94]
_ = x[actToggleSort-95] _ = x[actReplaceQuery-95]
_ = x[actShowPreview-96] _ = x[actToggleSort-96]
_ = x[actHidePreview-97] _ = x[actShowPreview-97]
_ = x[actTogglePreview-98] _ = x[actHidePreview-98]
_ = x[actTogglePreviewWrap-99] _ = x[actTogglePreview-99]
_ = x[actTogglePreviewWrapWord-100] _ = x[actTogglePreviewWrap-100]
_ = x[actTransform-101] _ = x[actTogglePreviewWrapWord-101]
_ = x[actTransformBorderLabel-102] _ = x[actTransform-102]
_ = x[actTransformGhost-103] _ = x[actTransformBorderLabel-103]
_ = x[actTransformHeader-104] _ = x[actTransformGhost-104]
_ = x[actTransformFooter-105] _ = x[actTransformHeader-105]
_ = x[actTransformHeaderLabel-106] _ = x[actTransformHeaderLines-106]
_ = x[actTransformFooterLabel-107] _ = x[actTransformFooter-107]
_ = x[actTransformInputLabel-108] _ = x[actTransformHeaderLabel-108]
_ = x[actTransformListLabel-109] _ = x[actTransformFooterLabel-109]
_ = x[actTransformNth-110] _ = x[actTransformInputLabel-110]
_ = x[actTransformPointer-111] _ = x[actTransformListLabel-111]
_ = x[actTransformPreviewLabel-112] _ = x[actTransformNth-112]
_ = x[actTransformPrompt-113] _ = x[actTransformPointer-113]
_ = x[actTransformQuery-114] _ = x[actTransformPreviewLabel-114]
_ = x[actTransformSearch-115] _ = x[actTransformPrompt-115]
_ = x[actTrigger-116] _ = x[actTransformQuery-116]
_ = x[actBgTransform-117] _ = x[actTransformSearch-117]
_ = x[actBgTransformBorderLabel-118] _ = x[actTrigger-118]
_ = x[actBgTransformGhost-119] _ = x[actBgTransform-119]
_ = x[actBgTransformHeader-120] _ = x[actBgTransformBorderLabel-120]
_ = x[actBgTransformFooter-121] _ = x[actBgTransformGhost-121]
_ = x[actBgTransformHeaderLabel-122] _ = x[actBgTransformHeader-122]
_ = x[actBgTransformFooterLabel-123] _ = x[actBgTransformHeaderLines-123]
_ = x[actBgTransformInputLabel-124] _ = x[actBgTransformFooter-124]
_ = x[actBgTransformListLabel-125] _ = x[actBgTransformHeaderLabel-125]
_ = x[actBgTransformNth-126] _ = x[actBgTransformFooterLabel-126]
_ = x[actBgTransformPointer-127] _ = x[actBgTransformInputLabel-127]
_ = x[actBgTransformPreviewLabel-128] _ = x[actBgTransformListLabel-128]
_ = x[actBgTransformPrompt-129] _ = x[actBgTransformNth-129]
_ = x[actBgTransformQuery-130] _ = x[actBgTransformPointer-130]
_ = x[actBgTransformSearch-131] _ = x[actBgTransformPreviewLabel-131]
_ = x[actBgCancel-132] _ = x[actBgTransformPrompt-132]
_ = x[actSearch-133] _ = x[actBgTransformQuery-133]
_ = x[actPreview-134] _ = x[actBgTransformSearch-134]
_ = x[actPreviewTop-135] _ = x[actBgCancel-135]
_ = x[actPreviewBottom-136] _ = x[actSearch-136]
_ = x[actPreviewUp-137] _ = x[actPreview-137]
_ = x[actPreviewDown-138] _ = x[actPreviewTop-138]
_ = x[actPreviewPageUp-139] _ = x[actPreviewBottom-139]
_ = x[actPreviewPageDown-140] _ = x[actPreviewUp-140]
_ = x[actPreviewHalfPageUp-141] _ = x[actPreviewDown-141]
_ = x[actPreviewHalfPageDown-142] _ = x[actPreviewPageUp-142]
_ = x[actPrevHistory-143] _ = x[actPreviewPageDown-143]
_ = x[actPrevSelected-144] _ = x[actPreviewHalfPageUp-144]
_ = x[actPrint-145] _ = x[actPreviewHalfPageDown-145]
_ = x[actPut-146] _ = x[actPrevHistory-146]
_ = x[actNextHistory-147] _ = x[actPrevSelected-147]
_ = x[actNextSelected-148] _ = x[actPrint-148]
_ = x[actExecute-149] _ = x[actPut-149]
_ = x[actExecuteSilent-150] _ = x[actNextHistory-150]
_ = x[actExecuteMulti-151] _ = x[actNextSelected-151]
_ = x[actSigStop-152] _ = x[actExecute-152]
_ = x[actBest-153] _ = x[actExecuteSilent-153]
_ = x[actFirst-154] _ = x[actExecuteMulti-154]
_ = x[actLast-155] _ = x[actSigStop-155]
_ = x[actReload-156] _ = x[actBest-156]
_ = x[actReloadSync-157] _ = x[actFirst-157]
_ = x[actDisableSearch-158] _ = x[actLast-158]
_ = x[actEnableSearch-159] _ = x[actReload-159]
_ = x[actSelect-160] _ = x[actReloadSync-160]
_ = x[actDeselect-161] _ = x[actDisableSearch-161]
_ = x[actUnbind-162] _ = x[actEnableSearch-162]
_ = x[actRebind-163] _ = x[actSelect-163]
_ = x[actToggleBind-164] _ = x[actDeselect-164]
_ = x[actBecome-165] _ = x[actUnbind-165]
_ = x[actShowHeader-166] _ = x[actRebind-166]
_ = x[actHideHeader-167] _ = x[actToggleBind-167]
_ = x[actBell-168] _ = x[actBecome-168]
_ = x[actExclude-169] _ = x[actShowHeader-169]
_ = x[actExcludeMulti-170] _ = x[actHideHeader-170]
_ = x[actAsync-171] _ = x[actBell-171]
_ = x[actExclude-172]
_ = x[actExcludeMulti-173]
_ = x[actAsync-174]
} }
const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleWrapWordactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTogglePreviewWrapWordactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsync" 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, 992, 1010, 1026, 1038, 1050, 1063, 1078, 1092, 1104, 1116, 1133, 1140, 1152, 1157, 1167, 1176, 1187, 1198, 1211, 1226, 1237, 1250, 1265, 1272, 1285, 1298, 1315, 1330, 1343, 1357, 1371, 1387, 1407, 1431, 1443, 1466, 1483, 1501, 1519, 1542, 1565, 1587, 1608, 1623, 1642, 1666, 1684, 1701, 1719, 1729, 1743, 1768, 1787, 1807, 1827, 1852, 1877, 1901, 1924, 1941, 1962, 1988, 2008, 2027, 2047, 2058, 2067, 2077, 2090, 2106, 2118, 2132, 2148, 2166, 2186, 2208, 2222, 2237, 2245, 2251, 2265, 2280, 2290, 2306, 2321, 2331, 2338, 2346, 2353, 2362, 2375, 2391, 2406, 2415, 2426, 2435, 2444, 2457, 2466, 2479, 2492, 2499, 2509, 2524, 2532} 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 { func (i actionType) String() string {
if i < 0 || i >= actionType(len(_actionType_index)-1) { if i < 0 || i >= actionType(len(_actionType_index)-1) {

View File

@@ -52,6 +52,20 @@ func (cl *ChunkList) lastChunk() *Chunk {
return cl.chunks[len(cl.chunks)-1] 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 // CountItems returns the total number of Items
func CountItems(cs []*Chunk) int { func CountItems(cs []*Chunk) int {
if len(cs) == 0 { if len(cs) == 0 {

View File

@@ -39,7 +39,7 @@ const (
progressMinDuration = 200 * time.Millisecond progressMinDuration = 200 * time.Millisecond
// Capacity of each chunk // Capacity of each chunk
chunkSize int = 100 chunkSize int = 1000
// Pre-allocated memory slices to minimize GC // Pre-allocated memory slices to minimize GC
slab16Size int = 100 * 1024 // 200KB * 32 = 12.8MB slab16Size int = 100 * 1024 // 200KB * 32 = 12.8MB
@@ -65,7 +65,6 @@ const (
EvtSearchNew EvtSearchNew
EvtSearchProgress EvtSearchProgress
EvtSearchFin EvtSearchFin
EvtHeader
EvtReady EvtReady
EvtQuit EvtQuit
) )

View File

@@ -2,6 +2,7 @@
package fzf package fzf
import ( import (
"fmt"
"maps" "maps"
"os" "os"
"sync" "sync"
@@ -17,7 +18,6 @@ Reader -> EvtReadNew -> Matcher (restart)
Terminal -> EvtSearchNew:bool -> Matcher (restart) Terminal -> EvtSearchNew:bool -> Matcher (restart)
Matcher -> EvtSearchProgress -> Terminal (update info) Matcher -> EvtSearchProgress -> Terminal (update info)
Matcher -> EvtSearchFin -> Terminal (update list) Matcher -> EvtSearchFin -> Terminal (update list)
Matcher -> EvtHeader -> Terminal (update header)
*/ */
type revision struct { type revision struct {
@@ -113,14 +113,8 @@ func Run(opts *Options) (int, error) {
cache := NewChunkCache() cache := NewChunkCache()
var chunkList *ChunkList var chunkList *ChunkList
var itemIndex int32 var itemIndex int32
header := make([]string, 0, opts.HeaderLines)
if opts.WithNth == nil { if opts.WithNth == nil {
chunkList = NewChunkList(cache, func(item *Item, data []byte) bool { 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, item.colors = ansiProcessor(data)
item.text.Index = itemIndex item.text.Index = itemIndex
itemIndex++ itemIndex++
@@ -147,11 +141,6 @@ func Run(opts *Options) (int, error) {
} }
} }
transformed := nthTransformer(tokens, itemIndex) transformed := nthTransformer(tokens, itemIndex)
if len(header) < opts.HeaderLines {
header = append(header, transformed)
eventBox.Set(EvtHeader, header)
return false
}
item.text, item.colors = ansiProcessor(stringBytes(transformed)) item.text, item.colors = ansiProcessor(stringBytes(transformed))
// We should not trim trailing whitespaces with background colors // We should not trim trailing whitespaces with background colors
@@ -193,7 +182,7 @@ func Run(opts *Options) (int, error) {
} }
// Reader // Reader
streamingFilter := opts.Filter != nil && !sort && !opts.Tac && !opts.Sync streamingFilter := opts.Filter != nil && !sort && !opts.Tac && !opts.Sync && opts.Bench == 0
var reader *Reader var reader *Reader
if !streamingFilter { if !streamingFilter {
reader = NewReader(func(data []byte) bool { reader = NewReader(func(data []byte) bool {
@@ -236,15 +225,17 @@ func Run(opts *Options) (int, error) {
denylist = make(map[int32]struct{}) denylist = make(map[int32]struct{})
denyMutex.Unlock() denyMutex.Unlock()
} }
headerLines := int32(opts.HeaderLines)
headerUpdated := false
patternBuilder := func(runes []rune) *Pattern { patternBuilder := func(runes []rune) *Pattern {
denyMutex.Lock() denyMutex.Lock()
denylistCopy := maps.Clone(denylist) denylistCopy := maps.Clone(denylist)
denyMutex.Unlock() denyMutex.Unlock()
return BuildPattern(cache, patternCache, return BuildPattern(cache, patternCache,
opts.Fuzzy, opts.FuzzyAlgo, opts.Extended, opts.Case, opts.Normalize, forward, withPos, 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 // Filtering mode
if opts.Filter != nil { if opts.Filter != nil {
@@ -265,8 +256,11 @@ func Run(opts *Options) (int, error) {
func(runes []byte) bool { func(runes []byte) bool {
item := Item{} item := Item{}
if chunkList.trans(&item, runes) { if chunkList.trans(&item, runes) {
if item.Index() < headerLines {
return false
}
mutex.Lock() mutex.Lock()
if result, _, _ := pattern.MatchItem(&item, false, slab); result != nil { if result, _, _ := pattern.MatchItem(&item, false, slab); result.item != nil {
opts.Printer(transformer(&item)) opts.Printer(transformer(&item))
found = true found = true
} }
@@ -281,6 +275,46 @@ func Run(opts *Options) (int, error) {
// NOTE: Streaming filter is inherently not compatible with --tail // NOTE: Streaming filter is inherently not compatible with --tail
snapshot, _, _ := chunkList.Snapshot(opts.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{ result := matcher.scan(MatchRequest{
chunks: snapshot, chunks: snapshot,
pattern: pattern}) pattern: pattern})
@@ -330,10 +364,11 @@ func Run(opts *Options) (int, error) {
query := []rune{} query := []rune{}
determine := func(final bool) { determine := func(final bool) {
if heightUnknown { if heightUnknown {
if total >= maxFit || final { items := max(0, total-int(headerLines))
if items >= maxFit || final {
deferred = false deferred = false
heightUnknown = false heightUnknown = false
terminal.startChan <- fitpad{min(total, maxFit), padHeight} terminal.startChan <- fitpad{min(items, maxFit), padHeight}
} }
} else if deferred { } else if deferred {
deferred = false deferred = false
@@ -349,11 +384,11 @@ func Run(opts *Options) (int, error) {
clearDenylist() clearDenylist()
} }
reading = true reading = true
headerUpdated = false
startTick = ticks startTick = ticks
chunkList.Clear() chunkList.Clear()
itemIndex = 0 itemIndex = 0
inputRevision.bumpMajor() inputRevision.bumpMajor()
header = make([]string, 0, opts.HeaderLines)
readyChan := make(chan bool) readyChan := make(chan bool)
go reader.restart(command, environ, readyChan) go reader.restart(command, environ, readyChan)
<-readyChan <-readyChan
@@ -411,7 +446,11 @@ func Run(opts *Options) (int, error) {
snapshotRevision = inputRevision snapshotRevision = inputRevision
} }
total = count 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 { if heightUnknown && !deferred {
determine(!reading) determine(!reading)
} }
@@ -421,6 +460,7 @@ func Run(opts *Options) (int, error) {
var command *commandSpec var command *commandSpec
var environ []string var environ []string
var changed bool var changed bool
headerLinesChanged := false
switch val := value.(type) { switch val := value.(type) {
case searchRequest: case searchRequest:
sort = val.sort sort = val.sort
@@ -441,6 +481,12 @@ func Run(opts *Options) (int, error) {
nth = *val.nth nth = *val.nth
bump = true bump = true
} }
if val.headerLines != nil {
headerLines = int32(*val.headerLines)
headerUpdated = false
headerLinesChanged = true
bump = true
}
if bump { if bump {
patternCache = make(map[string]*Pattern) patternCache = make(map[string]*Pattern)
cache.Clear() cache.Clear()
@@ -477,6 +523,14 @@ func Run(opts *Options) (int, error) {
snapshotRevision = inputRevision 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) matcher.Reset(snapshot, input(), true, !reading, sort, snapshotRevision)
delay = false delay = false
@@ -486,11 +540,6 @@ func Run(opts *Options) (int, error) {
terminal.UpdateProgress(val) terminal.UpdateProgress(val)
} }
case EvtHeader:
headerPadded := make([]string, opts.HeaderLines)
copy(headerPadded, value.([]string))
terminal.UpdateHeader(headerPadded)
case EvtSearchFin: case EvtSearchFin:
switch val := value.(type) { switch val := value.(type) {
case MatchResult: case MatchResult:

View File

@@ -3,7 +3,6 @@ package fzf
import ( import (
"fmt" "fmt"
"runtime" "runtime"
"sort"
"sync" "sync"
"time" "time"
@@ -43,6 +42,7 @@ type Matcher struct {
reqBox *util.EventBox reqBox *util.EventBox
partitions int partitions int
slab []*util.Slab slab []*util.Slab
sortBuf [][]Result
mergerCache map[string]MatchResult mergerCache map[string]MatchResult
revision revision revision revision
} }
@@ -54,8 +54,11 @@ const (
// NewMatcher returns a new Matcher // NewMatcher returns a new Matcher
func NewMatcher(cache *ChunkCache, patternBuilder func([]rune) *Pattern, func NewMatcher(cache *ChunkCache, patternBuilder func([]rune) *Pattern,
sort bool, tac bool, eventBox *util.EventBox, revision revision) *Matcher { sort bool, tac bool, eventBox *util.EventBox, revision revision, threads int) *Matcher {
partitions := min(numPartitionsMultiplier*runtime.NumCPU(), maxPartitions) partitions := min(numPartitionsMultiplier*runtime.NumCPU(), maxPartitions)
if threads > 0 {
partitions = threads
}
return &Matcher{ return &Matcher{
cache: cache, cache: cache,
patternBuilder: patternBuilder, patternBuilder: patternBuilder,
@@ -65,6 +68,7 @@ func NewMatcher(cache *ChunkCache, patternBuilder func([]rune) *Pattern,
reqBox: util.NewEventBox(), reqBox: util.NewEventBox(),
partitions: partitions, partitions: partitions,
slab: make([]*util.Slab, partitions), slab: make([]*util.Slab, partitions),
sortBuf: make([][]Result, partitions),
mergerCache: make(map[string]MatchResult), mergerCache: make(map[string]MatchResult),
revision: revision} revision: revision}
} }
@@ -174,7 +178,7 @@ func (m *Matcher) scan(request MatchRequest) MatchResult {
return MatchResult{m, m, false} return MatchResult{m, m, false}
} }
pattern := request.pattern pattern := request.pattern
passMerger := PassMerger(&request.chunks, m.tac, request.revision) passMerger := PassMerger(&request.chunks, m.tac, request.revision, pattern.startIndex)
if pattern.IsEmpty() { if pattern.IsEmpty() {
return MatchResult{passMerger, passMerger, false} return MatchResult{passMerger, passMerger, false}
} }
@@ -212,11 +216,7 @@ func (m *Matcher) scan(request MatchRequest) MatchResult {
sliceMatches = append(sliceMatches, matches...) sliceMatches = append(sliceMatches, matches...)
} }
if m.sort && request.pattern.sortable { if m.sort && request.pattern.sortable {
if m.tac { m.sortBuf[idx] = radixSortResults(sliceMatches, m.tac, m.sortBuf[idx])
sort.Sort(ByRelevanceTac(sliceMatches))
} else {
sort.Sort(ByRelevance(sliceMatches))
}
} }
resultChan <- partialResult{idx, sliceMatches} resultChan <- partialResult{idx, sliceMatches}
}(idx, m.slab[idx], chunks) }(idx, m.slab[idx], chunks)

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 // Merger holds a set of locally sorted lists of items and provides the view of
// a single, globally-sorted list // a single, globally-sorted list
type Merger struct { type Merger struct {
pattern *Pattern pattern *Pattern
lists [][]Result lists [][]Result
merged []Result merged []Result
chunks *[]*Chunk chunks *[]*Chunk
cursors []int cursors []int
sorted bool sorted bool
tac bool tac bool
final bool final bool
count int count int
pass bool pass bool
revision revision startIndex int
minIndex int32 revision revision
maxIndex int32 minIndex int32
maxIndex int32
} }
// PassMerger returns a new Merger that simply returns the items in the // PassMerger returns a new Merger that simply returns the items in the
// original order // original order. startIndex items are skipped from the beginning.
func PassMerger(chunks *[]*Chunk, tac bool, revision revision) *Merger { func PassMerger(chunks *[]*Chunk, tac bool, revision revision, startIndex int32) *Merger {
var minIndex, maxIndex int32 var minIndex, maxIndex int32
if len(*chunks) > 0 { if len(*chunks) > 0 {
minIndex = (*chunks)[0].items[0].Index() minIndex = (*chunks)[0].items[0].Index()
maxIndex = (*chunks)[len(*chunks)-1].lastIndex(minIndex) maxIndex = (*chunks)[len(*chunks)-1].lastIndex(minIndex)
} }
si := int(startIndex)
mg := Merger{ mg := Merger{
pattern: nil, pattern: nil,
chunks: chunks, chunks: chunks,
tac: tac, tac: tac,
count: 0, count: 0,
pass: true, pass: true,
revision: revision, startIndex: si,
minIndex: minIndex, revision: revision,
maxIndex: maxIndex} minIndex: minIndex + startIndex,
maxIndex: maxIndex}
for _, chunk := range *mg.chunks { for _, chunk := range *mg.chunks {
mg.count += chunk.count mg.count += chunk.count
} }
mg.count = max(0, mg.count-si)
return &mg return &mg
} }
@@ -113,6 +117,7 @@ func (mg *Merger) Get(idx int) Result {
if mg.tac { if mg.tac {
idx = mg.count - idx - 1 idx = mg.count - idx - 1
} }
idx += mg.startIndex
firstChunk := (*mg.chunks)[0] firstChunk := (*mg.chunks)[0]
if firstChunk.count < chunkSize && idx >= firstChunk.count { if firstChunk.count < chunkSize && idx >= firstChunk.count {
idx -= firstChunk.count idx -= firstChunk.count

View File

@@ -8,6 +8,7 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"time"
"unicode" "unicode"
"github.com/junegunn/fzf/src/algo" "github.com/junegunn/fzf/src/algo"
@@ -677,6 +678,8 @@ type Options struct {
WalkerSkip []string WalkerSkip []string
Version bool Version bool
Help bool Help bool
Threads int
Bench time.Duration
CPUProfile string CPUProfile string
MEMProfile string MEMProfile string
BlockProfile string BlockProfile string
@@ -1626,7 +1629,7 @@ const (
func init() { func init() {
executeRegexp = regexp.MustCompile( 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("[,:]+") splitRegexp = regexp.MustCompile("[,:]+")
actionNameRegexp = regexp.MustCompile("(?i)^[a-z-]+") actionNameRegexp = regexp.MustCompile("(?i)^[a-z-]+")
} }
@@ -2037,6 +2040,8 @@ func isExecuteAction(str string) actionType {
return actPreview return actPreview
case "change-header": case "change-header":
return actChangeHeader return actChangeHeader
case "change-header-lines":
return actChangeHeaderLines
case "change-footer": case "change-footer":
return actChangeFooter return actChangeFooter
case "change-list-label": case "change-list-label":
@@ -2097,6 +2102,8 @@ func isExecuteAction(str string) actionType {
return actTransformFooter return actTransformFooter
case "transform-header": case "transform-header":
return actTransformHeader return actTransformHeader
case "transform-header-lines":
return actTransformHeaderLines
case "transform-ghost": case "transform-ghost":
return actTransformGhost return actTransformGhost
case "transform-nth": case "transform-nth":
@@ -2127,6 +2134,8 @@ func isExecuteAction(str string) actionType {
return actBgTransformFooter return actBgTransformFooter
case "bg-transform-header": case "bg-transform-header":
return actBgTransformHeader return actBgTransformHeader
case "bg-transform-header-lines":
return actBgTransformHeaderLines
case "bg-transform-ghost": case "bg-transform-ghost":
return actBgTransformGhost return actBgTransformGhost
case "bg-transform-nth": case "bg-transform-nth":
@@ -3367,6 +3376,23 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
return err return err
} }
opts.WalkerSkip = filterNonEmpty(strings.Split(str, ",")) 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": case "--profile-cpu":
if opts.CPUProfile, err = nextString("file path required: cpu"); err != nil { if opts.CPUProfile, err = nextString("file path required: cpu"); err != nil {
return err return err

View File

@@ -64,6 +64,9 @@ type Pattern struct {
procFun map[termType]algo.Algo procFun map[termType]algo.Algo
cache *ChunkCache cache *ChunkCache
denylist map[int32]struct{} denylist map[int32]struct{}
startIndex int32
directAlgo algo.Algo
directTerm *term
} }
var _splitRegex *regexp.Regexp var _splitRegex *regexp.Regexp
@@ -74,7 +77,7 @@ func init() {
// BuildPattern builds Pattern object from the given arguments // 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, 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 var asString string
if extended { if extended {
@@ -146,9 +149,11 @@ func BuildPattern(cache *ChunkCache, patternCache map[string]*Pattern, fuzzy boo
delimiter: delimiter, delimiter: delimiter,
cache: cache, cache: cache,
denylist: denylist, denylist: denylist,
startIndex: startIndex,
procFun: make(map[termType]algo.Algo)} procFun: make(map[termType]algo.Algo)}
ptr.cacheKey = ptr.buildCacheKey() ptr.cacheKey = ptr.buildCacheKey()
ptr.directAlgo, ptr.directTerm = ptr.buildDirectAlgo(fuzzyAlgo)
ptr.procFun[termFuzzy] = fuzzyAlgo ptr.procFun[termFuzzy] = fuzzyAlgo
ptr.procFun[termEqual] = algo.EqualMatch ptr.procFun[termEqual] = algo.EqualMatch
ptr.procFun[termExact] = algo.ExactMatchNaive ptr.procFun[termExact] = algo.ExactMatchNaive
@@ -272,6 +277,22 @@ func (p *Pattern) buildCacheKey() string {
return strings.Join(cacheableTerms, "\t") 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 // CacheKey is used to build string to be used as the key of result cache
func (p *Pattern) CacheKey() string { func (p *Pattern) CacheKey() string {
return p.cacheKey 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 { func (p *Pattern) matchChunk(chunk *Chunk, space []Result, slab *util.Slab) []Result {
matches := []Result{} matches := []Result{}
if len(p.denylist) == 0 { // Skip header items in chunks that contain them
// Huge code duplication for minimizing unnecessary map lookups 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 { if space == nil {
for idx := 0; idx < chunk.count; idx++ { for idx := startIdx; idx < chunk.count; idx++ {
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match != nil { res, _ := p.directAlgo(t.caseSensitive, t.normalize, p.forward,
matches = append(matches, *match) &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 { } else {
for _, result := range space { for _, result := range space {
if match, _, _ := p.MatchItem(result.item, p.withPos, slab); match != nil { res, _ := p.directAlgo(t.caseSensitive, t.normalize, p.forward,
matches = append(matches, *match) &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 { 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 { if _, prs := p.denylist[chunk.items[idx].Index()]; prs {
continue continue
} }
if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match != nil { if match, _, _ := p.MatchItem(&chunk.items[idx], p.withPos, slab); match.item != nil {
matches = append(matches, *match) matches = append(matches, match)
} }
} }
} else { } 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 { if _, prs := p.denylist[result.item.Index()]; prs {
continue continue
} }
if match, _, _ := p.MatchItem(result.item, p.withPos, slab); match != nil { if match, _, _ := p.MatchItem(result.item, p.withPos, slab); match.item != nil {
matches = append(matches, *match) matches = append(matches, match)
} }
} }
} }
return matches return matches
} }
// MatchItem returns true if the Item is a match // MatchItem returns the match result if the Item is a match.
func (p *Pattern) MatchItem(item *Item, withPos bool, slab *util.Slab) (*Result, []Offset, *[]int) { // 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 p.extended {
if offsets, bonus, pos := p.extendedMatch(item, withPos, slab); len(offsets) == len(p.termSets) { if offsets, bonus, pos := p.extendedMatch(item, withPos, slab); len(offsets) == len(p.termSets) {
result := buildResult(item, offsets, bonus) return buildResult(item, offsets, bonus), offsets, pos
return &result, offsets, pos
} }
return nil, nil, nil return Result{}, nil, nil
} }
offset, bonus, pos := p.basicMatch(item, withPos, slab) offset, bonus, pos := p.basicMatch(item, withPos, slab)
if sidx := offset[0]; sidx >= 0 { if sidx := offset[0]; sidx >= 0 {
offsets := []Offset{offset} offsets := []Offset{offset}
result := buildResult(item, offsets, bonus) return buildResult(item, offsets, bonus), offsets, pos
return &result, offsets, pos
} }
return nil, nil, nil return Result{}, nil, nil
} }
func (p *Pattern) basicMatch(item *Item, withPos bool, slab *util.Slab) (Offset, int, *[]int) { func (p *Pattern) basicMatch(item *Item, withPos bool, slab *util.Slab) (Offset, int, *[]int) {

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 { withPos bool, cacheable bool, nth []Range, delimiter Delimiter, runes []rune) *Pattern {
return BuildPattern(NewChunkCache(), make(map[string]*Pattern), return BuildPattern(NewChunkCache(), make(map[string]*Pattern),
fuzzy, fuzzyAlgo, extended, caseMode, normalize, forward, 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) { func TestExact(t *testing.T) {

View File

@@ -33,8 +33,6 @@ func buildResult(item *Item, offsets []Offset, score int) Result {
sort.Sort(ByOrder(offsets)) sort.Sort(ByOrder(offsets))
} }
result := Result{item: item}
numChars := item.text.Length()
minBegin := math.MaxUint16 minBegin := math.MaxUint16
minEnd := math.MaxUint16 minEnd := math.MaxUint16
maxEnd := 0 maxEnd := 0
@@ -49,6 +47,14 @@ func buildResult(item *Item, offsets []Offset, score int) Result {
} }
} }
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 { for idx, criterion := range sortCriteria {
val := uint16(math.MaxUint16) val := uint16(math.MaxUint16)
switch criterion { switch criterion {
@@ -75,7 +81,6 @@ func buildResult(item *Item, offsets []Offset, score int) Result {
val = item.TrimLength() val = item.TrimLength()
case byPathname: case byPathname:
if validOffsetFound { if validOffsetFound {
// lastDelim := strings.LastIndexByte(item.text.ToString(), '/')
lastDelim := -1 lastDelim := -1
s := item.text.ToString() s := item.text.ToString()
for i := len(s) - 1; i >= 0; i-- { for i := len(s) - 1; i >= 0; i-- {
@@ -334,3 +339,79 @@ func (a ByRelevanceTac) Swap(i, j int) {
func (a ByRelevanceTac) Less(i, j int) bool { func (a ByRelevanceTac) Less(i, j int) bool {
return compareRanks(a[i], a[j], true) 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
}

View File

@@ -1,4 +1,4 @@
//go:build !386 && !amd64 //go:build !386 && !amd64 && !arm64
package fzf package fzf
@@ -14,3 +14,7 @@ func compareRanks(irank Result, jrank Result, tac bool) bool {
} }
return (irank.item.Index() <= jrank.item.Index()) != tac 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
}

View File

@@ -2,6 +2,7 @@ package fzf
import ( import (
"math" "math"
"math/rand"
"sort" "sort"
"testing" "testing"
@@ -182,3 +183,60 @@ func TestColorOffset(t *testing.T) {
assert(11, 39, 40, tui.NewColorPair(4, 8, tui.Bold)) 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
}
}
}
}
}

View File

@@ -1,4 +1,4 @@
//go:build 386 || amd64 //go:build 386 || amd64 || arm64
package fzf package fzf
@@ -14,3 +14,7 @@ func compareRanks(irank Result, jrank Result, tac bool) bool {
} }
return (irank.item.Index() <= jrank.item.Index()) != tac return (irank.item.Index() <= jrank.item.Index()) != tac
} }
func sortKey(r *Result) uint64 {
return *(*uint64)(unsafe.Pointer(&r.points[0]))
}

View File

@@ -314,6 +314,7 @@ type Terminal struct {
sort bool sort bool
toggleSort bool toggleSort bool
track trackOption track trackOption
targetIndex int32
delimiter Delimiter delimiter Delimiter
expect map[tui.Event]string expect map[tui.Event]string
keymap map[tui.Event][]*action keymap map[tui.Event][]*action
@@ -327,7 +328,7 @@ type Terminal struct {
headerVisible bool headerVisible bool
headerFirst bool headerFirst bool
headerLines int headerLines int
header []string header []Item
header0 []string header0 []string
footer []string footer []string
ellipsis string ellipsis string
@@ -542,6 +543,7 @@ const (
actChangeBorderLabel actChangeBorderLabel
actChangeGhost actChangeGhost
actChangeHeader actChangeHeader
actChangeHeaderLines
actChangeFooter actChangeFooter
actChangeHeaderLabel actChangeHeaderLabel
actChangeFooterLabel actChangeFooterLabel
@@ -627,6 +629,7 @@ const (
actTransformBorderLabel actTransformBorderLabel
actTransformGhost actTransformGhost
actTransformHeader actTransformHeader
actTransformHeaderLines
actTransformFooter actTransformFooter
actTransformHeaderLabel actTransformHeaderLabel
actTransformFooterLabel actTransformFooterLabel
@@ -645,6 +648,7 @@ const (
actBgTransformBorderLabel actBgTransformBorderLabel
actBgTransformGhost actBgTransformGhost
actBgTransformHeader actBgTransformHeader
actBgTransformHeaderLines
actBgTransformFooter actBgTransformFooter
actBgTransformHeaderLabel actBgTransformHeaderLabel
actBgTransformFooterLabel actBgTransformFooterLabel
@@ -710,6 +714,7 @@ func processExecution(action actionType) bool {
actTransformBorderLabel, actTransformBorderLabel,
actTransformGhost, actTransformGhost,
actTransformHeader, actTransformHeader,
actTransformHeaderLines,
actTransformFooter, actTransformFooter,
actTransformHeaderLabel, actTransformHeaderLabel,
actTransformFooterLabel, actTransformFooterLabel,
@@ -725,6 +730,7 @@ func processExecution(action actionType) bool {
actBgTransformBorderLabel, actBgTransformBorderLabel,
actBgTransformGhost, actBgTransformGhost,
actBgTransformHeader, actBgTransformHeader,
actBgTransformHeaderLines,
actBgTransformFooter, actBgTransformFooter,
actBgTransformHeaderLabel, actBgTransformHeaderLabel,
actBgTransformFooterLabel, actBgTransformFooterLabel,
@@ -761,14 +767,15 @@ type placeholderFlags struct {
} }
type searchRequest struct { type searchRequest struct {
sort bool sort bool
sync bool sync bool
nth *[]Range nth *[]Range
command *commandSpec headerLines *int
environ []string command *commandSpec
changed bool environ []string
denylist []int32 changed bool
revision revision denylist []int32
revision revision
} }
type previewRequest struct { type previewRequest struct {
@@ -1022,6 +1029,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
sort: opts.Sort > 0, sort: opts.Sort > 0,
toggleSort: opts.ToggleSort, toggleSort: opts.ToggleSort,
track: opts.Track, track: opts.Track,
targetIndex: minItem.Index(),
delimiter: opts.Delimiter, delimiter: opts.Delimiter,
expect: opts.Expect, expect: opts.Expect,
keymap: opts.Keymap, keymap: opts.Keymap,
@@ -1063,7 +1071,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
headerFirst: opts.HeaderFirst, headerFirst: opts.HeaderFirst,
headerLines: opts.HeaderLines, headerLines: opts.HeaderLines,
gap: opts.Gap, gap: opts.Gap,
header: []string{}, header: []Item{},
footer: opts.Footer, footer: opts.Footer,
header0: opts.Header, header0: opts.Header,
ansi: opts.Ansi, ansi: opts.Ansi,
@@ -1755,8 +1763,14 @@ func (t *Terminal) changeFooter(footer string) {
} }
// UpdateHeader updates the header // UpdateHeader updates the header
func (t *Terminal) UpdateHeader(header []string) { func (t *Terminal) UpdateHeader(header []Item) {
t.mutex.Lock() t.mutex.Lock()
// Pad to t.headerLines so that click coordinate mapping works correctly
if len(header) < t.headerLines {
padded := make([]Item, t.headerLines)
copy(padded, header)
header = padded
}
t.header = header t.header = header
t.mutex.Unlock() t.mutex.Unlock()
t.reqBox.Set(reqHeader, nil) t.reqBox.Set(reqHeader, nil)
@@ -1788,6 +1802,10 @@ func (t *Terminal) UpdateList(result MatchResult) {
prevIndex = merger.First().item.Index() prevIndex = merger.First().item.Index()
} }
} }
if t.targetIndex != minItem.Index() {
prevIndex = t.targetIndex
t.targetIndex = minItem.Index()
}
t.progress = 100 t.progress = 100
t.merger = merger t.merger = merger
t.resultMerger = merger t.resultMerger = merger
@@ -3079,11 +3097,11 @@ func (t *Terminal) printHeader() {
} }
t.withWindow(t.headerWindow, func() { t.withWindow(t.headerWindow, func() {
var lines []string var headerItems []Item
if !t.hasHeaderLinesWindow() { if !t.hasHeaderLinesWindow() {
lines = t.header headerItems = t.header
} }
t.printHeaderImpl(t.headerWindow, t.headerBorderShape, t.header0, lines) t.printHeaderImpl(t.headerWindow, t.headerBorderShape, t.header0, headerItems)
}) })
if w, shape := t.determineHeaderLinesShape(); w { if w, shape := t.determineHeaderLinesShape(); w {
t.withWindow(t.headerLinesWindow, func() { t.withWindow(t.headerLinesWindow, func() {
@@ -3145,7 +3163,7 @@ func (t *Terminal) headerIndentImpl(base int, borderShape tui.BorderShape) int {
return indentSize return indentSize
} }
func (t *Terminal) printHeaderImpl(window tui.Window, borderShape tui.BorderShape, lines1 []string, lines2 []string) { func (t *Terminal) printHeaderImpl(window tui.Window, borderShape tui.BorderShape, lines1 []string, lines2 []Item) {
max := t.window.Height() max := t.window.Height()
if !t.inputless && t.inputWindow == nil && window == nil && t.headerFirst { if !t.inputless && t.inputWindow == nil && window == nil && t.headerFirst {
max-- max--
@@ -3172,7 +3190,8 @@ func (t *Terminal) printHeaderImpl(window tui.Window, borderShape tui.BorderShap
} }
indent := strings.Repeat(" ", indentSize) indent := strings.Repeat(" ", indentSize)
t.wrap = false t.wrap = false
for idx, lineStr := range append(append([]string{}, lines1...), lines2...) { totalLines := len(lines1) + len(lines2)
for idx := 0; idx < totalLines; idx++ {
line := idx line := idx
if needReverse && idx < len(lines1) { if needReverse && idx < len(lines1) {
line = len(lines1) - idx - 1 line = len(lines1) - idx - 1
@@ -3186,11 +3205,18 @@ func (t *Terminal) printHeaderImpl(window tui.Window, borderShape tui.BorderShap
if line >= max { if line >= max {
continue continue
} }
trimmed, colors, newState := extractColor(lineStr, state, nil)
state = newState var item *Item
item := &Item{ if idx < len(lines1) {
text: util.ToChars([]byte(trimmed)), trimmed, colors, newState := extractColor(lines1[idx], state, nil)
colors: colors} state = newState
item = &Item{
text: util.ToChars([]byte(trimmed)),
colors: colors}
} else {
headerItem := lines2[idx-len(lines1)]
item = &headerItem
}
t.printHighlighted(Result{item: item}, t.printHighlighted(Result{item: item},
tui.ColHeader, tui.ColHeader, false, false, false, line, line, true, tui.ColHeader, tui.ColHeader, false, false, false, line, line, true,
@@ -5288,9 +5314,13 @@ func (t *Terminal) addClickHeaderWord(env []string) []string {
return env return env
} }
// NOTE: t.header is padded with empty strings so that its size is equal to t.headerLines
nthBase := 0 nthBase := 0
headers := [2][]string{t.header, t.header0} // Convert header items to strings for click handling
headerStrs := make([]string, len(t.header))
for i, item := range t.header {
headerStrs[i] = item.text.ToString()
}
headers := [2][]string{headerStrs, t.header0}
if t.layout == layoutReverse { if t.layout == layoutReverse {
headers[0], headers[1] = headers[1], headers[0] headers[0], headers[1] = headers[1], headers[0]
} }
@@ -5892,6 +5922,7 @@ func (t *Terminal) Loop() error {
events := []util.EventType{} events := []util.EventType{}
changed := false changed := false
var newNth *[]Range var newNth *[]Range
var newHeaderLines *int
req := func(evts ...util.EventType) { req := func(evts ...util.EventType) {
for _, event := range evts { for _, event := range evts {
events = append(events, event) events = append(events, event)
@@ -5908,6 +5939,7 @@ func (t *Terminal) Loop() error {
events = []util.EventType{} events = []util.EventType{}
changed = false changed = false
newNth = nil newNth = nil
newHeaderLines = nil
beof := false beof := false
queryChanged := false queryChanged := false
denylist := []int32{} denylist := []int32{}
@@ -6247,6 +6279,23 @@ func (t *Terminal) Loop() error {
} }
case actPrintQuery: case actPrintQuery:
req(reqPrintQuery) req(reqPrintQuery)
case actChangeHeaderLines, actTransformHeaderLines, actBgTransformHeaderLines:
capture(true, func(expr string) {
if n, err := strconv.Atoi(expr); err == nil && n >= 0 && n != t.headerLines {
t.headerLines = n
newHeaderLines = &n
changed = true
// Deselect items that are now part of the header
for idx := range t.selected {
if idx < int32(n) {
delete(t.selected, idx)
}
}
// Tell UpdateList to reposition cursor to the current item
t.targetIndex = t.currentIndex()
req(reqList, reqPrompt, reqInfo, reqHeader)
}
})
case actChangeMulti: case actChangeMulti:
multi := t.multi multi := t.multi
if a.a == "" { if a.a == "" {
@@ -7428,7 +7477,7 @@ func (t *Terminal) Loop() error {
reload := changed || newCommand != nil reload := changed || newCommand != nil
var reloadRequest *searchRequest var reloadRequest *searchRequest
if reload { if reload {
reloadRequest = &searchRequest{sort: t.sort, sync: reloadSync, nth: newNth, command: newCommand, environ: t.environ(), changed: changed, denylist: denylist, revision: t.resultMerger.Revision()} reloadRequest = &searchRequest{sort: t.sort, sync: reloadSync, nth: newNth, headerLines: newHeaderLines, command: newCommand, environ: t.environ(), changed: changed, denylist: denylist, revision: t.resultMerger.Revision()}
} }
// Dispatch queued background requests // Dispatch queued background requests

View File

@@ -1755,7 +1755,7 @@ class TestCore < TestInteractive
end end
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 = { expected = {
FZF_DIRECTION: 'down', FZF_DIRECTION: 'down',
FZF_TOTAL_COUNT: '100', FZF_TOTAL_COUNT: '100',
@@ -2176,6 +2176,80 @@ class TestCore < TestInteractive
end end
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 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.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| tmux.until do |lines|

View File

@@ -326,4 +326,27 @@ class TestFilter < TestBase
writelines(['emp001 Alice Engineering', 'emp002 Bob Marketing']) writelines(['emp001 Alice Engineering', 'emp002 Bob Marketing'])
assert_equal 'emp001', `#{FZF} -d' ' --with-nth 2 --accept-nth 1 -f Alice < #{tempname}`.chomp assert_equal 'emp001', `#{FZF} -d' ' --with-nth 2 --accept-nth 1 -f Alice < #{tempname}`.chomp
end 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 end