From 7220d8233e81291db8bda3d4eb5286ca45f07be0 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sun, 26 Jan 2025 01:50:08 +0900 Subject: [PATCH] Add 'search' and 'transform-search' Close #4202 --- ADVANCED.md | 47 +++++++++++++++++++++++-- CHANGELOG.md | 21 +++++++++++ man/man1/fzf.1 | 6 ++-- src/actiontype_string.go | 75 +++++++++++++++++++++------------------- src/options.go | 12 ++++--- src/terminal.go | 48 +++++++++++++++++++------ test/test_core.rb | 18 ++++++++++ 7 files changed, 173 insertions(+), 54 deletions(-) diff --git a/ADVANCED.md b/ADVANCED.md index bafe9708..2636b15f 100644 --- a/ADVANCED.md +++ b/ADVANCED.md @@ -1,8 +1,8 @@ Advanced fzf examples ====================== -* *Last update: 2024/06/24* -* *Requires fzf 0.54.0 or later* +* *Last update: 2025/01/26* +* *Requires fzf 0.59.0 or later* --- @@ -22,6 +22,7 @@ Advanced fzf examples * [Switching to fzf-only search mode](#switching-to-fzf-only-search-mode) * [Switching between Ripgrep mode and fzf mode](#switching-between-ripgrep-mode-and-fzf-mode) * [Switching between Ripgrep mode and fzf mode using a single key binding](#switching-between-ripgrep-mode-and-fzf-mode-using-a-single-key-binding) + * [Controlling Ripgrap search and fzf search simultaneously](#controlling-ripgrap-search-and-fzf-search-simultaneously) * [Log tailing](#log-tailing) * [Key bindings for git objects](#key-bindings-for-git-objects) * [Files listed in `git status`](#files-listed-in-git-status) @@ -500,6 +501,48 @@ fzf --ansi --disabled --query "$INITIAL_QUERY" \ --bind 'enter:become(vim {1} +{2})' ``` +### Controlling Ripgrap search and fzf search simultaneously + +fzf 0.59.0 added `search` action that allows you to trigger an fzf search +with an arbitrary query string. This means fzf is no longer restricted to the +exact query entered in the prompt. + +In the example below, `transform` action is used to conditionally trigger +either `reload` for ripgrep or `search` for fzf. The first word of the query +initiates the Ripgrep process to generate the initial results, while the +remainder of the query is passed to fzf for secondary filtering. + +```sh +#!/usr/bin/env bash + +# Switch between Ripgrep mode and fzf filtering mode (CTRL-T) +RG_PREFIX="rg --column --line-number --no-heading --color=always --smart-case " +INITIAL_QUERY="${*:-}" +TRANSFORMER=' + words=($FZF_QUERY) + + # If $FZF_QUERY contains multiple words, drop the first word, + # and trigger fzf search with the rest + if [[ ${#words[@]} -gt 1 ]]; then + echo "search:${FZF_QUERY#* }" + + # Otherwise, if the query does not end with a space, + # restart ripgrep and reload the list + elif ! [[ $FZF_QUERY =~ \ $ ]]; then + echo "reload:sleep 0.1; $RG_PREFIX \"${words[0]}\" || true" + fi +' +fzf --ansi --disabled --query "$INITIAL_QUERY" \ + --with-shell 'bash -c' \ + --bind "start:transform:$TRANSFORMER" \ + --bind "change:transform:$TRANSFORMER" \ + --color "hl:-1:underline,hl+:-1:underline:reverse" \ + --delimiter : \ + --preview 'bat --color=always {1} --highlight-line {2}' \ + --preview-window 'up,60%,border-bottom,+{2}+3/3,~3' \ + --bind 'enter:become(vim {1} +{2})' +``` + Log tailing ----------- diff --git a/CHANGELOG.md b/CHANGELOG.md index a808a5b7..ab795978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,27 @@ CHANGELOG --bind 'ctrl-r:reload(ps -ef)' --header 'Press CTRL-R to reload' \ --header-lines-border bottom --no-list-border ``` +- Added `search(...)` and `transform-search(...)` action to trigger an fzf search with an arbitrary query string. This can be used to extend the search syntax of fzf. In the following example, fzf will use the first word of the query to trigger ripgrep search, and use the rest of the query to perform fzf search within the result. + ```sh + TRANSFORMER=' + words=($FZF_QUERY) + + # If $FZF_QUERY contains multiple words, drop the first word, + # and trigger fzf search with the rest + if [[ ${#words[@]} -gt 1 ]]; then + echo "search:${FZF_QUERY#* }" + + # Otherwise, if the query does not end with a space, + # restart ripgrep and reload the list + elif ! [[ $FZF_QUERY =~ \ $ ]]; then + echo "reload:rg --column --color=always --smart-case \"${words[0]}\"" + fi + ' + fzf --ansi --disabled \ + --with-shell 'bash -c' \ + --bind "start:transform:$TRANSFORMER" \ + --bind "change:transform:$TRANSFORMER" + ``` - Added `bell` action to ring the terminal bell ```sh # Press CTRL-Y to copy the current line to the clipboard and ring the bell diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 2377ba03..89883b4b 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -94,8 +94,8 @@ more weight to the chronological ordering. This also sets .RS fzf chooses \fBpath\fR scheme when the input is a TTY device, where fzf would start its built-in walker or run \fB$FZF_DEFAULT_COMMAND\fR, and there is no -\fBreload\fR action bound to \fBstart\fR event. Otherwise, it chooses -\fBdefault\fR scheme. +\fBreload\fR or \fBtransform\fR action bound to \fBstart\fR event. Otherwise, +it chooses \fBdefault\fR scheme. .RE .TP @@ -1609,6 +1609,7 @@ A key or an event can be bound to one or more of the following actions. \fBreload(...)\fR (see below for the details) \fBreload\-sync(...)\fR (see below for the details) \fBreplace\-query\fR (replace query string with the current selection) + \fBsearch(...)\fR (trigger fzf search with the given string) \fBselect\fR \fBselect\-all\fR (select all matches) \fBshow\-header\fR @@ -1639,6 +1640,7 @@ A key or an event can be bound to one or more of the following actions. \fBtransform\-preview\-label(...)\fR (transform preview label using an external command) \fBtransform\-prompt(...)\fR (transform prompt string using an external command) \fBtransform\-query(...)\fR (transform query string using an external command) + \fBtransform\-search(...)\fR (trigger fzf search with the output of an external command) \fBunbind(...)\fR (unbind bindings) \fBunix\-line\-discard\fR \fIctrl\-u\fR \fBunix\-word\-rubout\fR \fIctrl\-w\fR diff --git a/src/actiontype_string.go b/src/actiontype_string.go index 4459e251..143e02b6 100644 --- a/src/actiontype_string.go +++ b/src/actiontype_string.go @@ -96,45 +96,48 @@ func _() { _ = x[actTransformPreviewLabel-85] _ = x[actTransformPrompt-86] _ = x[actTransformQuery-87] - _ = x[actPreview-88] - _ = x[actChangePreview-89] - _ = x[actChangePreviewWindow-90] - _ = x[actPreviewTop-91] - _ = x[actPreviewBottom-92] - _ = x[actPreviewUp-93] - _ = x[actPreviewDown-94] - _ = x[actPreviewPageUp-95] - _ = x[actPreviewPageDown-96] - _ = x[actPreviewHalfPageUp-97] - _ = x[actPreviewHalfPageDown-98] - _ = x[actPrevHistory-99] - _ = x[actPrevSelected-100] - _ = x[actPrint-101] - _ = x[actPut-102] - _ = x[actNextHistory-103] - _ = x[actNextSelected-104] - _ = x[actExecute-105] - _ = x[actExecuteSilent-106] - _ = x[actExecuteMulti-107] - _ = x[actSigStop-108] - _ = x[actFirst-109] - _ = x[actLast-110] - _ = x[actReload-111] - _ = x[actReloadSync-112] - _ = x[actDisableSearch-113] - _ = x[actEnableSearch-114] - _ = x[actSelect-115] - _ = x[actDeselect-116] - _ = x[actUnbind-117] - _ = x[actRebind-118] - _ = x[actBecome-119] - _ = x[actShowHeader-120] - _ = x[actHideHeader-121] + _ = x[actTransformSearch-88] + _ = x[actSearch-89] + _ = x[actPreview-90] + _ = x[actChangePreview-91] + _ = x[actChangePreviewWindow-92] + _ = x[actPreviewTop-93] + _ = x[actPreviewBottom-94] + _ = x[actPreviewUp-95] + _ = x[actPreviewDown-96] + _ = x[actPreviewPageUp-97] + _ = x[actPreviewPageDown-98] + _ = x[actPreviewHalfPageUp-99] + _ = x[actPreviewHalfPageDown-100] + _ = x[actPrevHistory-101] + _ = x[actPrevSelected-102] + _ = x[actPrint-103] + _ = x[actPut-104] + _ = x[actNextHistory-105] + _ = x[actNextSelected-106] + _ = x[actExecute-107] + _ = x[actExecuteSilent-108] + _ = x[actExecuteMulti-109] + _ = x[actSigStop-110] + _ = x[actFirst-111] + _ = x[actLast-112] + _ = x[actReload-113] + _ = x[actReloadSync-114] + _ = x[actDisableSearch-115] + _ = x[actEnableSearch-116] + _ = x[actSelect-117] + _ = x[actDeselect-118] + _ = x[actUnbind-119] + _ = x[actRebind-120] + _ = x[actBecome-121] + _ = x[actShowHeader-122] + _ = x[actHideHeader-123] + _ = x[actBell-124] } -const _actionType_name = "actIgnoreactStartactClickactInvalidactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactCancelactChangeBorderLabelactChangeListLabelactChangeInputLabelactChangeHeaderactChangeHeaderLabelactChangeMultiactChangePreviewLabelactChangePromptactChangeQueryactChangeNthactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactKillLineactKillWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleMultiLineactToggleHscrollactTrackCurrentactUntrackCurrentactDownactUpactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTransformactTransformBorderLabelactTransformListLabelactTransformInputLabelactTransformHeaderactTransformHeaderLabelactTransformPreviewLabelactTransformPromptactTransformQueryactPreviewactChangePreviewactChangePreviewWindowactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactBecomeactShowHeaderactHideHeader" +const _actionType_name = "actIgnoreactStartactClickactInvalidactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactCancelactChangeBorderLabelactChangeListLabelactChangeInputLabelactChangeHeaderactChangeHeaderLabelactChangeMultiactChangePreviewLabelactChangePromptactChangeQueryactChangeNthactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactKillLineactKillWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleMultiLineactToggleHscrollactTrackCurrentactUntrackCurrentactDownactUpactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTransformactTransformBorderLabelactTransformListLabelactTransformInputLabelactTransformHeaderactTransformHeaderLabelactTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactSearchactPreviewactChangePreviewactChangePreviewWindowactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactBecomeactShowHeaderactHideHeaderactBell" -var _actionType_index = [...]uint16{0, 9, 17, 25, 35, 42, 50, 68, 76, 85, 102, 123, 138, 159, 183, 198, 207, 227, 245, 264, 279, 299, 313, 334, 349, 363, 375, 389, 402, 419, 427, 440, 456, 468, 476, 490, 504, 515, 526, 544, 561, 568, 587, 599, 613, 622, 637, 649, 662, 673, 684, 696, 710, 731, 746, 759, 777, 793, 808, 825, 832, 837, 846, 857, 868, 881, 896, 907, 920, 935, 942, 955, 968, 985, 1000, 1013, 1027, 1041, 1057, 1077, 1089, 1112, 1133, 1155, 1173, 1196, 1220, 1238, 1255, 1265, 1281, 1303, 1316, 1332, 1344, 1358, 1374, 1392, 1412, 1434, 1448, 1463, 1471, 1477, 1491, 1506, 1516, 1532, 1547, 1557, 1565, 1572, 1581, 1594, 1610, 1625, 1634, 1645, 1654, 1663, 1672, 1685, 1698} +var _actionType_index = [...]uint16{0, 9, 17, 25, 35, 42, 50, 68, 76, 85, 102, 123, 138, 159, 183, 198, 207, 227, 245, 264, 279, 299, 313, 334, 349, 363, 375, 389, 402, 419, 427, 440, 456, 468, 476, 490, 504, 515, 526, 544, 561, 568, 587, 599, 613, 622, 637, 649, 662, 673, 684, 696, 710, 731, 746, 759, 777, 793, 808, 825, 832, 837, 846, 857, 868, 881, 896, 907, 920, 935, 942, 955, 968, 985, 1000, 1013, 1027, 1041, 1057, 1077, 1089, 1112, 1133, 1155, 1173, 1196, 1220, 1238, 1255, 1273, 1282, 1292, 1308, 1330, 1343, 1359, 1371, 1385, 1401, 1419, 1439, 1461, 1475, 1490, 1498, 1504, 1518, 1533, 1543, 1559, 1574, 1584, 1592, 1599, 1608, 1621, 1637, 1652, 1661, 1672, 1681, 1690, 1699, 1712, 1725, 1732} func (i actionType) String() string { if i < 0 || i >= actionType(len(_actionType_index)-1) { diff --git a/src/options.go b/src/options.go index 9d233b57..2b9a5196 100644 --- a/src/options.go +++ b/src/options.go @@ -1332,7 +1332,7 @@ const ( func init() { executeRegexp = regexp.MustCompile( - `(?si)[:+](become|execute(?:-multi|-silent)?|reload(?:-sync)?|preview|(?:change|transform)-(?:query|prompt|(?:border|list|preview|input|header)-label|header)|transform|change-(?:preview-window|preview|multi|nth)|(?:re|un)bind|pos|put|print)`) + `(?si)[:+](become|execute(?:-multi|-silent)?|reload(?:-sync)?|preview|(?:change|transform)-(?:query|prompt|(?:border|list|preview|input|header)-label|header|search)|transform|change-(?:preview-window|preview|multi|nth)|(?:re|un)bind|pos|put|print|search)`) splitRegexp = regexp.MustCompile("[,:]+") actionNameRegexp = regexp.MustCompile("(?i)^[a-z-]+") } @@ -1744,6 +1744,10 @@ func isExecuteAction(str string) actionType { return actTransformPrompt case "transform-query": return actTransformQuery + case "transform-search": + return actTransformSearch + case "search": + return actSearch } return actIgnore } @@ -3252,7 +3256,7 @@ func ParseOptions(useDefaults bool, args []string) (*Options, error) { // 1. explicitly set --scheme=default, // 2. or replace $FZF_DEFAULT_COMMAND with an equivalent 'start:reload' // binding, which is the new preferred way. - if !opts.hasReloadOnStart() && util.IsTty(os.Stdin) { + if !opts.hasReloadOrTransformOnStart() && util.IsTty(os.Stdin) { opts.Scheme = "path" } _, opts.Criteria, _ = parseScheme(opts.Scheme) @@ -3267,10 +3271,10 @@ func ParseOptions(useDefaults bool, args []string) (*Options, error) { return opts, nil } -func (opts *Options) hasReloadOnStart() bool { +func (opts *Options) hasReloadOrTransformOnStart() bool { if actions, prs := opts.Keymap[tui.Start.AsEvent()]; prs { for _, action := range actions { - if action.t == actReload || action.t == actReloadSync { + if action.t == actReload || action.t == actReloadSync || action.t == actTransform { return true } } diff --git a/src/terminal.go b/src/terminal.go index 30523de3..4d58ca8c 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -277,6 +277,7 @@ type Terminal struct { xoffset int yanked []rune input []rune + inputOverride *[]rune multi int multiLine bool sort bool @@ -533,6 +534,8 @@ const ( actTransformPreviewLabel actTransformPrompt actTransformQuery + actTransformSearch + actSearch actPreview actChangePreview actChangePreviewWindow @@ -1354,7 +1357,13 @@ func (t *Terminal) getScrollbar() (int, int) { func (t *Terminal) Input() (bool, []rune) { t.mutex.Lock() defer t.mutex.Unlock() - return t.paused, copySlice(t.input) + paused := t.paused + src := t.input + if t.inputOverride != nil { + paused = false + src = *t.inputOverride + } + return paused, copySlice(src) } // UpdateCount updates the count information @@ -3837,6 +3846,14 @@ func (t *Terminal) fullRedraw() { t.printAll() } +func (t *Terminal) captureLine(template string) string { + return t.executeCommand(template, false, true, true, true, "") +} + +func (t *Terminal) captureLines(template string) string { + return t.executeCommand(template, false, true, true, false, "") +} + func (t *Terminal) executeCommand(template string, forcePlus bool, background bool, capture bool, firstLineOnly bool, info string) string { line := "" valid, list := t.buildPlusList(template, forcePlus) @@ -4751,12 +4768,12 @@ func (t *Terminal) Loop() error { req(reqPreviewRefresh) } case actTransformPrompt: - prompt := t.executeCommand(a.a, false, true, true, true, "") + prompt := t.captureLine(a.a) t.promptString = prompt t.prompt, t.promptLen = t.parsePrompt(prompt) req(reqPrompt) case actTransformQuery: - query := t.executeCommand(a.a, false, true, true, true, "") + query := t.captureLine(a.a) t.input = []rune(query) t.cx = len(t.input) case actToggleSort: @@ -4840,7 +4857,7 @@ func (t *Terminal) Loop() error { case actChangeHeader, actTransformHeader: header := a.a if a.t == actTransformHeader { - header = t.executeCommand(a.a, false, true, true, false, "") + header = t.captureLines(a.a) } if t.changeHeader(header) { req(reqHeader, reqList, reqPrompt, reqInfo) @@ -4878,40 +4895,40 @@ func (t *Terminal) Loop() error { req(reqRedrawPreviewLabel) } case actTransform: - body := t.executeCommand(a.a, false, true, true, false, "") + body := t.captureLines(a.a) if actions, err := parseSingleActionList(strings.Trim(body, "\r\n")); err == nil { return doActions(actions) } case actTransformHeaderLabel: - label := t.executeCommand(a.a, false, true, true, true, "") + label := t.captureLine(a.a) t.headerLabelOpts.label = label if t.headerBorder != nil { t.headerLabel, t.headerLabelLen = t.ansiLabelPrinter(label, &tui.ColHeaderLabel, false) req(reqRedrawHeaderLabel) } case actTransformInputLabel: - label := t.executeCommand(a.a, false, true, true, true, "") + label := t.captureLine(a.a) t.inputLabelOpts.label = label if t.inputBorder != nil { t.inputLabel, t.inputLabelLen = t.ansiLabelPrinter(label, &tui.ColInputLabel, false) req(reqRedrawInputLabel) } case actTransformListLabel: - label := t.executeCommand(a.a, false, true, true, true, "") + label := t.captureLine(a.a) t.listLabelOpts.label = label if t.wborder != nil { t.listLabel, t.listLabelLen = t.ansiLabelPrinter(label, &tui.ColListLabel, false) req(reqRedrawListLabel) } case actTransformBorderLabel: - label := t.executeCommand(a.a, false, true, true, true, "") + label := t.captureLine(a.a) t.borderLabelOpts.label = label if t.border != nil { t.borderLabel, t.borderLabelLen = t.ansiLabelPrinter(label, &tui.ColBorderLabel, false) req(reqRedrawBorderLabel) } case actTransformPreviewLabel: - label := t.executeCommand(a.a, false, true, true, true, "") + label := t.captureLine(a.a) t.previewLabelOpts.label = label if t.pborder != nil { t.previewLabel, t.previewLabelLen = t.ansiLabelPrinter(label, &tui.ColPreviewLabel, false) @@ -5309,6 +5326,14 @@ func (t *Terminal) Loop() error { t.track = trackDisabled } req(reqInfo) + case actSearch: + override := []rune(a.a) + t.inputOverride = &override + changed = true + case actTransformSearch: + override := []rune(t.captureLine(a.a)) + t.inputOverride = &override + changed = true case actEnableSearch: t.paused = false changed = true @@ -5734,6 +5759,9 @@ func (t *Terminal) Loop() error { } t.truncateQuery() queryChanged = string(previousInput) != string(t.input) + if queryChanged { + t.inputOverride = nil + } changed = changed || queryChanged if onChanges, prs := t.keymap[tui.Change.AsEvent()]; queryChanged && prs && !doActions(onChanges) { continue diff --git a/test/test_core.rb b/test/test_core.rb index 1524a885..0d80644b 100644 --- a/test/test_core.rb +++ b/test/test_core.rb @@ -1069,6 +1069,24 @@ class TestCore < TestInteractive tmux.until { |lines| assert_equal 'up', lines[-1] } end + def test_search + tmux.send_keys %(seq 100 | #{FZF} --query 0 --bind space:search:1), :Enter + tmux.until { |lines| assert_equal 10, lines.match_count } + tmux.send_keys :Space + tmux.until { |lines| assert_equal 20, lines.match_count } + tmux.send_keys '0' + tmux.until { |lines| assert_equal 1, lines.match_count } + end + + def test_transform_search + tmux.send_keys %(seq 1000 | #{FZF} --bind 'change:transform-search:echo {q}{q}'), :Enter + tmux.until { |lines| assert_equal 1000, lines.match_count } + tmux.send_keys '1' + tmux.until { |lines| assert_equal 28, lines.match_count } + tmux.send_keys :BSpace, '0' + tmux.until { |lines| assert_equal 10, lines.match_count } + end + def test_clear_selection tmux.send_keys %(seq 100 | #{FZF} --multi --bind space:clear-selection), :Enter tmux.until { |lines| assert_equal 100, lines.match_count }