From 7f85beccb5b6a86fd81db66876db835f9deb4e6e Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sat, 27 Apr 2024 14:11:08 +0900 Subject: [PATCH 1/6] [completion] Add undocumented bash variables for completion commands And allow empty FZF_COMPLETION_DIR_COMMANDS --- shell/completion.bash | 11 +++++++---- shell/completion.zsh | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/shell/completion.bash b/shell/completion.bash index 08637677..43fa46af 100644 --- a/shell/completion.bash +++ b/shell/completion.bash @@ -463,8 +463,11 @@ complete -o default -F _fzf_opts_completion fzf # fzf-tmux specific options (like `-w WIDTH`) are left as a future patch. complete -o default -F _fzf_opts_completion fzf-tmux -d_cmds="${FZF_COMPLETION_DIR_COMMANDS:-cd pushd rmdir}" -a_cmds=" +d_cmds="${FZF_COMPLETION_DIR_COMMANDS-cd pushd rmdir}" + +# NOTE: $FZF_COMPLETION_PATH_COMMANDS and $FZF_COMPLETION_VAR_COMMANDS are +# undocumented and subject to change in the future. +a_cmds="${FZF_COMPLETION_PATH_COMMANDS-" awk bat cat diff diff3 emacs emacsclient ex file ftp g++ gcc gvim head hg hx java javac ld less more mvim nvim patch perl python ruby @@ -472,8 +475,8 @@ a_cmds=" basename bunzip2 bzip2 chmod chown curl cp dirname du find git grep gunzip gzip hg jar ln ls mv open rm rsync scp - svn tar unzip zip" -v_cmds="export unset printenv" + svn tar unzip zip"}" +v_cmds="${FZF_COMPLETION_VAR_COMMANDS-export unset printenv}" # Preserve existing completion __fzf_orig_completion < <(complete -p $d_cmds $a_cmds $v_cmds unalias kill ssh 2> /dev/null) diff --git a/shell/completion.zsh b/shell/completion.zsh index 99cec29c..ddf5f4bb 100644 --- a/shell/completion.zsh +++ b/shell/completion.zsh @@ -327,7 +327,7 @@ fzf-completion() { # Trigger sequence given if [ ${#tokens} -gt 1 -a "$tail" = "$trigger" ]; then - d_cmds=(${=FZF_COMPLETION_DIR_COMMANDS:-cd pushd rmdir}) + d_cmds=(${=FZF_COMPLETION_DIR_COMMANDS-cd pushd rmdir}) [ -z "$trigger" ] && prefix=${tokens[-1]} || prefix=${tokens[-1]:0:-${#trigger}} if [[ $prefix = *'$('* ]] || [[ $prefix = *'<('* ]] || [[ $prefix = *'>('* ]] || [[ $prefix = *':='* ]] || [[ $prefix = *'`'* ]]; then From 608232568b2639c4ed71965e434debdd4b276fcd Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sat, 27 Apr 2024 17:38:06 +0900 Subject: [PATCH 2/6] Add 'change-multi' action Close #3754 --- CHANGELOG.md | 8 ++ man/man1/fzf.1 | 2 + src/actiontype_string.go | 187 ++++++++++++++++++++------------------- src/options.go | 6 +- src/terminal.go | 14 +++ test/test_go.rb | 19 ++++ 6 files changed, 142 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df2db318..51e9a603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ CHANGELOG ========= +0.51.0 +------ +- Added `change-multi` action for dynamically changing `--multi` option + - `change-multi` - enable multi-select mode with no limit + - `change-multi(NUM)` - enable multi-select mode with a limit + - `change-multi(0)` - disable multi-select mode +- Bug fixes and improvements + 0.50.0 ------ - Search performance optimization. You can observe 50%+ improvement in some scenarios. diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 0d131c7a..458c6a5f 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -1282,6 +1282,8 @@ A key or an event can be bound to one or more of the following actions. \fBcancel\fR (clear query string if not empty, abort fzf otherwise) \fBchange-border-label(...)\fR (change \fB--border-label\fR to the given string) \fBchange-header(...)\fR (change header to the given string; doesn't affect \fB--header-lines\fR) + \fBchange-multi\fR (enable multi-select mode with no limit) + \fBchange-multi(...)\fR (enable multi-select mode with a limit or disable it with 0) \fBchange-preview(...)\fR (change \fB--preview\fR option) \fBchange-preview-label(...)\fR (change \fB--preview-label\fR to the given string) \fBchange-preview-window(...)\fR (change \fB--preview-window\fR option; rotate through the multiple option sets separated by '|') diff --git a/src/actiontype_string.go b/src/actiontype_string.go index 341c4bd4..a9d931d7 100644 --- a/src/actiontype_string.go +++ b/src/actiontype_string.go @@ -26,102 +26,103 @@ func _() { _ = x[actCancel-15] _ = x[actChangeBorderLabel-16] _ = x[actChangeHeader-17] - _ = x[actChangePreviewLabel-18] - _ = x[actChangePrompt-19] - _ = x[actChangeQuery-20] - _ = x[actClearScreen-21] - _ = x[actClearQuery-22] - _ = x[actClearSelection-23] - _ = x[actClose-24] - _ = x[actDeleteChar-25] - _ = x[actDeleteCharEof-26] - _ = x[actEndOfLine-27] - _ = x[actForwardChar-28] - _ = x[actForwardWord-29] - _ = x[actKillLine-30] - _ = x[actKillWord-31] - _ = x[actUnixLineDiscard-32] - _ = x[actUnixWordRubout-33] - _ = x[actYank-34] - _ = x[actBackwardKillWord-35] - _ = x[actSelectAll-36] - _ = x[actDeselectAll-37] - _ = x[actToggle-38] - _ = x[actToggleSearch-39] - _ = x[actToggleAll-40] - _ = x[actToggleDown-41] - _ = x[actToggleUp-42] - _ = x[actToggleIn-43] - _ = x[actToggleOut-44] - _ = x[actToggleTrack-45] - _ = x[actToggleTrackCurrent-46] - _ = x[actToggleHeader-47] - _ = x[actTrackCurrent-48] - _ = x[actUntrackCurrent-49] - _ = x[actDown-50] - _ = x[actUp-51] - _ = x[actPageUp-52] - _ = x[actPageDown-53] - _ = x[actPosition-54] - _ = x[actHalfPageUp-55] - _ = x[actHalfPageDown-56] - _ = x[actOffsetUp-57] - _ = x[actOffsetDown-58] - _ = x[actJump-59] - _ = x[actJumpAccept-60] - _ = x[actPrintQuery-61] - _ = x[actRefreshPreview-62] - _ = x[actReplaceQuery-63] - _ = x[actToggleSort-64] - _ = x[actShowPreview-65] - _ = x[actHidePreview-66] - _ = x[actTogglePreview-67] - _ = x[actTogglePreviewWrap-68] - _ = x[actTransform-69] - _ = x[actTransformBorderLabel-70] - _ = x[actTransformHeader-71] - _ = x[actTransformPreviewLabel-72] - _ = x[actTransformPrompt-73] - _ = x[actTransformQuery-74] - _ = x[actPreview-75] - _ = x[actChangePreview-76] - _ = x[actChangePreviewWindow-77] - _ = x[actPreviewTop-78] - _ = x[actPreviewBottom-79] - _ = x[actPreviewUp-80] - _ = x[actPreviewDown-81] - _ = x[actPreviewPageUp-82] - _ = x[actPreviewPageDown-83] - _ = x[actPreviewHalfPageUp-84] - _ = x[actPreviewHalfPageDown-85] - _ = x[actPrevHistory-86] - _ = x[actPrevSelected-87] - _ = x[actPut-88] - _ = x[actNextHistory-89] - _ = x[actNextSelected-90] - _ = x[actExecute-91] - _ = x[actExecuteSilent-92] - _ = x[actExecuteMulti-93] - _ = x[actSigStop-94] - _ = x[actFirst-95] - _ = x[actLast-96] - _ = x[actReload-97] - _ = x[actReloadSync-98] - _ = x[actDisableSearch-99] - _ = x[actEnableSearch-100] - _ = x[actSelect-101] - _ = x[actDeselect-102] - _ = x[actUnbind-103] - _ = x[actRebind-104] - _ = x[actBecome-105] - _ = x[actResponse-106] - _ = x[actShowHeader-107] - _ = x[actHideHeader-108] + _ = x[actChangeMulti-18] + _ = x[actChangePreviewLabel-19] + _ = x[actChangePrompt-20] + _ = x[actChangeQuery-21] + _ = x[actClearScreen-22] + _ = x[actClearQuery-23] + _ = x[actClearSelection-24] + _ = x[actClose-25] + _ = x[actDeleteChar-26] + _ = x[actDeleteCharEof-27] + _ = x[actEndOfLine-28] + _ = x[actForwardChar-29] + _ = x[actForwardWord-30] + _ = x[actKillLine-31] + _ = x[actKillWord-32] + _ = x[actUnixLineDiscard-33] + _ = x[actUnixWordRubout-34] + _ = x[actYank-35] + _ = x[actBackwardKillWord-36] + _ = x[actSelectAll-37] + _ = x[actDeselectAll-38] + _ = x[actToggle-39] + _ = x[actToggleSearch-40] + _ = x[actToggleAll-41] + _ = x[actToggleDown-42] + _ = x[actToggleUp-43] + _ = x[actToggleIn-44] + _ = x[actToggleOut-45] + _ = x[actToggleTrack-46] + _ = x[actToggleTrackCurrent-47] + _ = x[actToggleHeader-48] + _ = x[actTrackCurrent-49] + _ = x[actUntrackCurrent-50] + _ = x[actDown-51] + _ = x[actUp-52] + _ = x[actPageUp-53] + _ = x[actPageDown-54] + _ = x[actPosition-55] + _ = x[actHalfPageUp-56] + _ = x[actHalfPageDown-57] + _ = x[actOffsetUp-58] + _ = x[actOffsetDown-59] + _ = x[actJump-60] + _ = x[actJumpAccept-61] + _ = x[actPrintQuery-62] + _ = x[actRefreshPreview-63] + _ = x[actReplaceQuery-64] + _ = x[actToggleSort-65] + _ = x[actShowPreview-66] + _ = x[actHidePreview-67] + _ = x[actTogglePreview-68] + _ = x[actTogglePreviewWrap-69] + _ = x[actTransform-70] + _ = x[actTransformBorderLabel-71] + _ = x[actTransformHeader-72] + _ = x[actTransformPreviewLabel-73] + _ = x[actTransformPrompt-74] + _ = x[actTransformQuery-75] + _ = x[actPreview-76] + _ = x[actChangePreview-77] + _ = x[actChangePreviewWindow-78] + _ = x[actPreviewTop-79] + _ = x[actPreviewBottom-80] + _ = x[actPreviewUp-81] + _ = x[actPreviewDown-82] + _ = x[actPreviewPageUp-83] + _ = x[actPreviewPageDown-84] + _ = x[actPreviewHalfPageUp-85] + _ = x[actPreviewHalfPageDown-86] + _ = x[actPrevHistory-87] + _ = x[actPrevSelected-88] + _ = x[actPut-89] + _ = x[actNextHistory-90] + _ = x[actNextSelected-91] + _ = x[actExecute-92] + _ = x[actExecuteSilent-93] + _ = x[actExecuteMulti-94] + _ = x[actSigStop-95] + _ = x[actFirst-96] + _ = x[actLast-97] + _ = x[actReload-98] + _ = x[actReloadSync-99] + _ = x[actDisableSearch-100] + _ = x[actEnableSearch-101] + _ = x[actSelect-102] + _ = x[actDeselect-103] + _ = x[actUnbind-104] + _ = x[actRebind-105] + _ = x[actBecome-106] + _ = x[actResponse-107] + _ = x[actShowHeader-108] + _ = x[actHideHeader-109] } -const _actionType_name = "actIgnoreactStartactClickactInvalidactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactCancelactChangeBorderLabelactChangeHeaderactChangePreviewLabelactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactForwardCharactForwardWordactKillLineactKillWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactTrackCurrentactUntrackCurrentactDownactUpactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTransformactTransformBorderLabelactTransformHeaderactTransformPreviewLabelactTransformPromptactTransformQueryactPreviewactChangePreviewactChangePreviewWindowactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactBecomeactResponseactShowHeaderactHideHeader" +const _actionType_name = "actIgnoreactStartactClickactInvalidactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactCancelactChangeBorderLabelactChangeHeaderactChangeMultiactChangePreviewLabelactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactForwardCharactForwardWordactKillLineactKillWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactTrackCurrentactUntrackCurrentactDownactUpactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTransformactTransformBorderLabelactTransformHeaderactTransformPreviewLabelactTransformPromptactTransformQueryactPreviewactChangePreviewactChangePreviewWindowactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactBecomeactResponseactShowHeaderactHideHeader" -var _actionType_index = [...]uint16{0, 9, 17, 25, 35, 42, 50, 68, 76, 85, 102, 123, 138, 159, 183, 198, 207, 227, 242, 263, 278, 292, 306, 319, 336, 344, 357, 373, 385, 399, 413, 424, 435, 453, 470, 477, 496, 508, 522, 531, 546, 558, 571, 582, 593, 605, 619, 640, 655, 670, 687, 694, 699, 708, 719, 730, 743, 758, 769, 782, 789, 802, 815, 832, 847, 860, 874, 888, 904, 924, 936, 959, 977, 1001, 1019, 1036, 1046, 1062, 1084, 1097, 1113, 1125, 1139, 1155, 1173, 1193, 1215, 1229, 1244, 1250, 1264, 1279, 1289, 1305, 1320, 1330, 1338, 1345, 1354, 1367, 1383, 1398, 1407, 1418, 1427, 1436, 1445, 1456, 1469, 1482} +var _actionType_index = [...]uint16{0, 9, 17, 25, 35, 42, 50, 68, 76, 85, 102, 123, 138, 159, 183, 198, 207, 227, 242, 256, 277, 292, 306, 320, 333, 350, 358, 371, 387, 399, 413, 427, 438, 449, 467, 484, 491, 510, 522, 536, 545, 560, 572, 585, 596, 607, 619, 633, 654, 669, 684, 701, 708, 713, 722, 733, 744, 757, 772, 783, 796, 803, 816, 829, 846, 861, 874, 888, 902, 918, 938, 950, 973, 991, 1015, 1033, 1050, 1060, 1076, 1098, 1111, 1127, 1139, 1153, 1169, 1187, 1207, 1229, 1243, 1258, 1264, 1278, 1293, 1303, 1319, 1334, 1344, 1352, 1359, 1368, 1381, 1397, 1412, 1421, 1432, 1441, 1450, 1459, 1470, 1483, 1496} 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 4ba8538f..c8a3fa15 100644 --- a/src/options.go +++ b/src/options.go @@ -1055,7 +1055,7 @@ const ( func init() { executeRegexp = regexp.MustCompile( - `(?si)[:+](become|execute(?:-multi|-silent)?|reload(?:-sync)?|preview|(?:change|transform)-(?:header|query|prompt|border-label|preview-label)|transform|change-preview-window|change-preview|(?:re|un)bind|pos|put)`) + `(?si)[:+](become|execute(?:-multi|-silent)?|reload(?:-sync)?|preview|(?:change|transform)-(?:header|query|prompt|border-label|preview-label)|transform|change-(?:preview-window|preview|multi)|(?:re|un)bind|pos|put)`) splitRegexp = regexp.MustCompile("[,:]+") actionNameRegexp = regexp.MustCompile("(?i)^[a-z-]+") } @@ -1306,6 +1306,8 @@ func parseActionList(masked string, original string, prevActions []*action, putA if t == actIgnore { if specIndex == 0 && specLower == "" { actions = append(prevActions, actions...) + } else if specLower == "change-multi" { + appendAction(actChangeMulti) } else { exit("unknown action: " + spec) } @@ -1407,6 +1409,8 @@ func isExecuteAction(str string) actionType { return actChangePrompt case "change-query": return actChangeQuery + case "change-multi": + return actChangeMulti case "pos": return actPosition case "execute": diff --git a/src/terminal.go b/src/terminal.go index b885ce25..25f30150 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -367,6 +367,7 @@ const ( actCancel actChangeBorderLabel actChangeHeader + actChangeMulti actChangePreviewLabel actChangePrompt actChangeQuery @@ -3489,6 +3490,19 @@ func (t *Terminal) Loop() { } case actPrintQuery: req(reqPrintQuery) + case actChangeMulti: + multi := t.multi + if a.a == "" { + multi = maxMulti + } else if n, e := strconv.Atoi(a.a); e == nil && n >= 0 { + multi = n + } + if t.multi > 0 && multi != t.multi { + t.selected = make(map[int32]selectedItem) + t.version++ + } + t.multi = multi + req(reqList, reqInfo) case actChangeQuery: t.input = []rune(a.a) t.cx = len(t.input) diff --git a/test/test_go.rb b/test/test_go.rb index ba067997..b08ac72b 100755 --- a/test/test_go.rb +++ b/test/test_go.rb @@ -425,6 +425,25 @@ class TestGoFZF < TestBase end end + def test_multi_action + tmux.send_keys "seq 10 | #{FZF} --bind 'a:change-multi,b:change-multi(3),c:change-multi(xxx),d:change-multi(0)'", :Enter + tmux.until { |lines| assert_equal 10, lines.item_count } + tmux.until { |lines| assert lines[-2]&.start_with?(' 10/10 ') } + tmux.send_keys 'a' + tmux.until { |lines| assert lines[-2]&.start_with?(' 10/10 (0)') } + tmux.send_keys 'b' + tmux.until { |lines| assert lines[-2]&.start_with?(' 10/10 (0/3)') } + tmux.send_keys :BTab + tmux.until { |lines| assert lines[-2]&.start_with?(' 10/10 (1/3)') } + tmux.send_keys 'c' + tmux.send_keys :BTab + tmux.until { |lines| assert lines[-2]&.start_with?(' 10/10 (2/3)') } + tmux.send_keys 'd' + tmux.until do |lines| + assert lines[-2]&.start_with?(' 10/10 ') && !lines[-2]&.include?('(') + end + end + def test_with_nth [true, false].each do |multi| tmux.send_keys "(echo ' 1st 2nd 3rd/'; From b86a967ee217f4c820249701218a17eaad2737ae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Apr 2024 17:57:11 +0900 Subject: [PATCH 3/6] Bump crate-ci/typos from 1.19.0 to 1.20.9 (#3749) Bumps [crate-ci/typos](https://github.com/crate-ci/typos) from 1.19.0 to 1.20.9. - [Release notes](https://github.com/crate-ci/typos/releases) - [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md) - [Commits](https://github.com/crate-ci/typos/compare/v1.19.0...v1.20.9) --- updated-dependencies: - dependency-name: crate-ci/typos dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/typos.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml index 752c58c6..91f21864 100644 --- a/.github/workflows/typos.yml +++ b/.github/workflows/typos.yml @@ -7,4 +7,4 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: crate-ci/typos@v1.19.0 + - uses: crate-ci/typos@v1.20.9 From a4391aeedd4fec1865d2d646711f58d04058531b Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sat, 27 Apr 2024 18:36:37 +0900 Subject: [PATCH 4/6] Add --with-shell for shelling out with different command and flags (#3746) Close #3732 --- CHANGELOG.md | 13 +++++ man/man1/fzf.1 | 14 +++++- src/core.go | 9 ++-- src/options.go | 10 ++-- src/reader.go | 7 +-- src/reader_test.go | 3 +- src/terminal.go | 53 +++++++++----------- src/terminal_test.go | 4 +- src/terminal_unix.go | 19 ------- src/terminal_windows.go | 26 ---------- src/util/util_unix.go | 56 ++++++++++++++++++--- src/util/util_windows.go | 106 +++++++++++++++++++++++++++------------ test/test_go.rb | 2 +- 13 files changed, 194 insertions(+), 128 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51e9a603..920bceff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,23 @@ CHANGELOG 0.51.0 ------ +- Added `--with-shell` option to start child processes with a custom shell command and flags + ```sh + gem list | fzf --with-shell 'ruby -e' \ + --preview 'pp Gem::Specification.find_by_name({1})' \ + --bind 'ctrl-o:execute-silent: + spec = Gem::Specification.find_by_name({1}) + [spec.homepage, *spec.metadata.filter { _1.end_with?("uri") }.values].uniq.each do + system "open", _1 + end + ' + ``` - Added `change-multi` action for dynamically changing `--multi` option - `change-multi` - enable multi-select mode with no limit - `change-multi(NUM)` - enable multi-select mode with a limit - `change-multi(0)` - disable multi-select mode +- `become` action is now supported on Windows + - Unlike in *nix, this does not use `execve(2)`. Instead it spawns a new process and waits for it to finish, so the exact behavior may differ. - Bug fixes and improvements 0.50.0 diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 458c6a5f..742fba5a 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -818,6 +818,16 @@ the finder only after the input stream is complete. e.g. \fBfzf --multi | fzf --sync\fR .RE .TP +.B "--with-shell=STR" +Shell command and flags to start child processes with. On *nix Systems, the +default value is \fB$SHELL -c\fR if \fB$SHELL\fR is set, otherwise \fBsh -c\fR. +On Windows, the default value is \fBcmd /v:on/s/c\fR when \fB$SHELL\fR is not +set. + +.RS +e.g. \fBgem list | fzf --with-shell 'ruby -e' --preview 'pp Gem::Specification.find_by_name({1})'\fR +.RE +.TP .B "--listen[=[ADDR:]PORT]" "--listen-unsafe[=[ADDR:]PORT]" Start HTTP server and listen on the given address. It allows external processes to send actions to perform via POST method. @@ -932,6 +942,8 @@ you need to protect against DNS rebinding and privilege escalation attacks. .br .BR 2 " Error" .br +.BR 127 " Invalid shell command for \fBbecome\fR action" +.br .BR 130 " Interrupted with \fBCTRL-C\fR or \fBESC\fR" .SH FIELD INDEX EXPRESSION @@ -1441,8 +1453,6 @@ call. \fBfzf --bind "enter:become(vim {})"\fR -\fBbecome(...)\fR is not supported on Windows. - .SS RELOAD INPUT \fBreload(...)\fR action is used to dynamically update the input list diff --git a/src/core.go b/src/core.go index ec137698..14aa781f 100644 --- a/src/core.go +++ b/src/core.go @@ -121,13 +121,16 @@ func Run(opts *Options, version string, revision string) { }) } + // Process executor + executor := util.NewExecutor(opts.WithShell) + // Reader streamingFilter := opts.Filter != nil && !sort && !opts.Tac && !opts.Sync var reader *Reader if !streamingFilter { reader = NewReader(func(data []byte) bool { return chunkList.Push(data) - }, eventBox, opts.ReadZero, opts.Filter == nil) + }, eventBox, executor, opts.ReadZero, opts.Filter == nil) go reader.ReadSource(opts.WalkerRoot, opts.WalkerOpts, opts.WalkerSkip) } @@ -178,7 +181,7 @@ func Run(opts *Options, version string, revision string) { mutex.Unlock() } return false - }, eventBox, opts.ReadZero, false) + }, eventBox, executor, opts.ReadZero, false) reader.ReadSource(opts.WalkerRoot, opts.WalkerOpts, opts.WalkerSkip) } else { eventBox.Unwatch(EvtReadNew) @@ -209,7 +212,7 @@ func Run(opts *Options, version string, revision string) { go matcher.Loop() // Terminal I/O - terminal := NewTerminal(opts, eventBox) + terminal := NewTerminal(opts, eventBox, executor) maxFit := 0 // Maximum number of items that can fit on screen padHeight := 0 heightUnknown := opts.Height.auto diff --git a/src/options.go b/src/options.go index c8a3fa15..66e0554e 100644 --- a/src/options.go +++ b/src/options.go @@ -120,6 +120,7 @@ const usage = `usage: fzf [options] --read0 Read input delimited by ASCII NUL characters --print0 Print output delimited by ASCII NUL characters --sync Synchronous search for multi-staged filtering + --with-shell=STR Shell command and flags to start child processes with --listen[=[ADDR:]PORT] Start HTTP server to receive actions (POST /) (To allow remote process execution, use --listen-unsafe) --version Display version information and exit @@ -356,6 +357,7 @@ type Options struct { Unicode bool Ambidouble bool Tabstop int + WithShell string ListenAddr *listenAddress Unsafe bool ClearOnExit bool @@ -1327,10 +1329,6 @@ func parseActionList(masked string, original string, prevActions []*action, putA actions = append(actions, &action{t: t, a: actionArg}) } switch t { - case actBecome: - if util.IsWindows() { - exit("become action is not supported on Windows") - } case actUnbind, actRebind: parseKeyChordsImpl(actionArg, spec[0:offset]+" target required", exit) case actChangePreviewWindow: @@ -1957,6 +1955,8 @@ func parseOptions(opts *Options, allArgs []string) { nextString(allArgs, &i, "padding required (TRBL / TB,RL / T,RL,B / T,R,B,L)")) case "--tabstop": opts.Tabstop = nextInt(allArgs, &i, "tab stop required") + case "--with-shell": + opts.WithShell = nextString(allArgs, &i, "shell command and flags required") case "--listen", "--listen-unsafe": given, str := optionalNextString(allArgs, &i) addr := defaultListenAddr @@ -2073,6 +2073,8 @@ func parseOptions(opts *Options, allArgs []string) { opts.Padding = parseMargin("padding", value) } else if match, value := optString(arg, "--tabstop="); match { opts.Tabstop = atoi(value) + } else if match, value := optString(arg, "--with-shell="); match { + opts.WithShell = value } else if match, value := optString(arg, "--listen="); match { addr, err := parseListenAddress(value) if err != nil { diff --git a/src/reader.go b/src/reader.go index 82648a68..8fa864e7 100644 --- a/src/reader.go +++ b/src/reader.go @@ -18,6 +18,7 @@ import ( // Reader reads from command or standard input type Reader struct { pusher func([]byte) bool + executor *util.Executor eventBox *util.EventBox delimNil bool event int32 @@ -30,8 +31,8 @@ type Reader struct { } // NewReader returns new Reader object -func NewReader(pusher func([]byte) bool, eventBox *util.EventBox, delimNil bool, wait bool) *Reader { - return &Reader{pusher, eventBox, delimNil, int32(EvtReady), make(chan bool, 1), sync.Mutex{}, nil, nil, false, wait} +func NewReader(pusher func([]byte) bool, eventBox *util.EventBox, executor *util.Executor, delimNil bool, wait bool) *Reader { + return &Reader{pusher, executor, eventBox, delimNil, int32(EvtReady), make(chan bool, 1), sync.Mutex{}, nil, nil, false, wait} } func (r *Reader) startEventPoller() { @@ -242,7 +243,7 @@ func (r *Reader) readFromCommand(command string, environ []string) bool { r.mutex.Lock() r.killed = false r.command = &command - r.exec = util.ExecCommand(command, true) + r.exec = r.executor.ExecCommand(command, true) if environ != nil { r.exec.Env = environ } diff --git a/src/reader_test.go b/src/reader_test.go index bf06fd09..56f9a1b0 100644 --- a/src/reader_test.go +++ b/src/reader_test.go @@ -10,9 +10,10 @@ import ( func TestReadFromCommand(t *testing.T) { strs := []string{} eb := util.NewEventBox() + exec := util.NewExecutor("") reader := NewReader( func(s []byte) bool { strs = append(strs, string(s)); return true }, - eb, false, true) + eb, exec, false, true) reader.startEventPoller() diff --git a/src/terminal.go b/src/terminal.go index 25f30150..8d114e1b 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -7,7 +7,6 @@ import ( "io" "math" "os" - "os/exec" "os/signal" "regexp" "sort" @@ -245,6 +244,7 @@ type Terminal struct { listenUnsafe bool borderShape tui.BorderShape cleanExit bool + executor *util.Executor paused bool border tui.Window window tui.Window @@ -640,7 +640,7 @@ func evaluateHeight(opts *Options, termHeight int) int { } // NewTerminal returns new Terminal object -func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal { +func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor) *Terminal { input := trimQuery(opts.Query) var delay time.Duration if opts.Tac { @@ -736,6 +736,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal { previewLabel: nil, previewLabelOpts: opts.PreviewLabel, cleanExit: opts.ClearOnExit, + executor: executor, paused: opts.Phony, cycle: opts.Cycle, headerVisible: true, @@ -2522,6 +2523,7 @@ type replacePlaceholderParams struct { allItems []*Item lastAction actionType prompt string + executor *util.Executor } func (t *Terminal) replacePlaceholder(template string, forcePlus bool, input string, list []*Item) string { @@ -2535,6 +2537,7 @@ func (t *Terminal) replacePlaceholder(template string, forcePlus bool, input str allItems: list, lastAction: t.lastAction, prompt: t.promptString, + executor: t.executor, }) } @@ -2595,7 +2598,7 @@ func replacePlaceholder(params replacePlaceholderParams) string { case escaped: return match case match == "{q}" || match == "{fzf:query}": - return quoteEntry(params.query) + return params.executor.QuoteEntry(params.query) case match == "{}": replace = func(item *Item) string { switch { @@ -2608,13 +2611,13 @@ func replacePlaceholder(params replacePlaceholderParams) string { case flags.file: return item.AsString(params.stripAnsi) default: - return quoteEntry(item.AsString(params.stripAnsi)) + return params.executor.QuoteEntry(item.AsString(params.stripAnsi)) } } case match == "{fzf:action}": return params.lastAction.Name() case match == "{fzf:prompt}": - return quoteEntry(params.prompt) + return params.executor.QuoteEntry(params.prompt) default: // token type and also failover (below) rangeExpressions := strings.Split(match[1:len(match)-1], ",") @@ -2648,7 +2651,7 @@ func replacePlaceholder(params replacePlaceholderParams) string { str = strings.TrimSpace(str) } if !flags.file { - str = quoteEntry(str) + str = params.executor.QuoteEntry(str) } return str } @@ -2688,7 +2691,7 @@ func (t *Terminal) executeCommand(template string, forcePlus bool, background bo return line } command := t.replacePlaceholder(template, forcePlus, string(t.input), list) - cmd := util.ExecCommand(command, false) + cmd := t.executor.ExecCommand(command, false) cmd.Env = t.environ() t.executing.Set(true) if !background { @@ -2965,7 +2968,7 @@ func (t *Terminal) Loop() { if items[0] != nil { _, query := t.Input() command := t.replacePlaceholder(commandTemplate, false, string(query), items) - cmd := util.ExecCommand(command, true) + cmd := t.executor.ExecCommand(command, true) env := t.environ() if pwindowSize.Lines > 0 { lines := fmt.Sprintf("LINES=%d", pwindowSize.Lines) @@ -3372,27 +3375,21 @@ func (t *Terminal) Loop() { valid, list := t.buildPlusList(a.a, false) if valid { command := t.replacePlaceholder(a.a, false, string(t.input), list) - shell := os.Getenv("SHELL") - if len(shell) == 0 { - shell = "sh" - } - shellPath, err := exec.LookPath(shell) - if err == nil { - t.tui.Close() - if t.history != nil { - t.history.append(string(t.input)) - } - /* - FIXME: It is not at all clear why this is required. - The following command will report 'not a tty', unless we open - /dev/tty *twice* after closing the standard input for 'reload' - in Reader.terminate(). - : | fzf --bind 'start:reload:ls' --bind 'enter:become:tty' - */ - tui.TtyIn() - util.SetStdin(tui.TtyIn()) - syscall.Exec(shellPath, []string{shell, "-c", command}, os.Environ()) + t.tui.Close() + if t.history != nil { + t.history.append(string(t.input)) } + + /* + FIXME: It is not at all clear why this is required. + The following command will report 'not a tty', unless we open + /dev/tty *twice* after closing the standard input for 'reload' + in Reader.terminate(). + + while : | fzf --bind 'start:reload:ls' --bind 'load:become:tty'; do echo; done + */ + tui.TtyIn() + t.executor.Become(tui.TtyIn(), t.environ(), command) } case actExecute, actExecuteSilent: t.executeCommand(a.a, false, a.t == actExecuteSilent, false, false) diff --git a/src/terminal_test.go b/src/terminal_test.go index e7d3e751..9fc53919 100644 --- a/src/terminal_test.go +++ b/src/terminal_test.go @@ -23,6 +23,7 @@ func replacePlaceholderTest(template string, stripAnsi bool, delimiter Delimiter allItems: allItems, lastAction: actBackwardDeleteCharEof, prompt: "prompt", + executor: util.NewExecutor(""), }) } @@ -244,6 +245,7 @@ func TestQuoteEntry(t *testing.T) { unixStyle := quotes{``, `'`, `'\''`, `"`, `\`} windowsStyle := quotes{`^`, `^"`, `'`, `\^"`, `\\`} var effectiveStyle quotes + exec := util.NewExecutor("") if util.IsWindows() { effectiveStyle = windowsStyle @@ -278,7 +280,7 @@ func TestQuoteEntry(t *testing.T) { } for input, expected := range tests { - escaped := quoteEntry(input) + escaped := exec.QuoteEntry(input) expected = templateToString(expected, effectiveStyle) if escaped != expected { t.Errorf("Input: %s, expected: %s, actual %s", input, expected, escaped) diff --git a/src/terminal_unix.go b/src/terminal_unix.go index c7fa7f12..d0b00f2f 100644 --- a/src/terminal_unix.go +++ b/src/terminal_unix.go @@ -5,26 +5,11 @@ package fzf import ( "os" "os/signal" - "strings" "syscall" "golang.org/x/sys/unix" ) -var escaper *strings.Replacer - -func init() { - tokens := strings.Split(os.Getenv("SHELL"), "/") - if tokens[len(tokens)-1] == "fish" { - // https://fishshell.com/docs/current/language.html#quotes - // > The only meaningful escape sequences in single quotes are \', which - // > escapes a single quote and \\, which escapes the backslash symbol. - escaper = strings.NewReplacer("\\", "\\\\", "'", "\\'") - } else { - escaper = strings.NewReplacer("'", "'\\''") - } -} - func notifyOnResize(resizeChan chan<- os.Signal) { signal.Notify(resizeChan, syscall.SIGWINCH) } @@ -41,7 +26,3 @@ func notifyStop(p *os.Process) { func notifyOnCont(resizeChan chan<- os.Signal) { signal.Notify(resizeChan, syscall.SIGCONT) } - -func quoteEntry(entry string) string { - return "'" + escaper.Replace(entry) + "'" -} diff --git a/src/terminal_windows.go b/src/terminal_windows.go index a1ea7a22..112cd68d 100644 --- a/src/terminal_windows.go +++ b/src/terminal_windows.go @@ -4,8 +4,6 @@ package fzf import ( "os" - "regexp" - "strings" ) func notifyOnResize(resizeChan chan<- os.Signal) { @@ -19,27 +17,3 @@ func notifyStop(p *os.Process) { func notifyOnCont(resizeChan chan<- os.Signal) { // NOOP } - -func quoteEntry(entry string) string { - shell := os.Getenv("SHELL") - if len(shell) == 0 { - shell = "cmd" - } - - if strings.Contains(shell, "cmd") { - // backslash escaping is done here for applications - // (see ripgrep test case in terminal_test.go#TestWindowsCommands) - escaped := strings.Replace(entry, `\`, `\\`, -1) - escaped = `"` + strings.Replace(escaped, `"`, `\"`, -1) + `"` - // caret is the escape character for cmd shell - r, _ := regexp.Compile(`[&|<>()@^%!"]`) - return r.ReplaceAllStringFunc(escaped, func(match string) string { - return "^" + match - }) - } else if strings.Contains(shell, "pwsh") || strings.Contains(shell, "powershell") { - escaped := strings.Replace(entry, `"`, `\"`, -1) - return "'" + strings.Replace(escaped, "'", "''", -1) + "'" - } else { - return "'" + strings.Replace(entry, "'", "'\\''", -1) + "'" - } -} diff --git a/src/util/util_unix.go b/src/util/util_unix.go index 2991fd2c..4410a9bf 100644 --- a/src/util/util_unix.go +++ b/src/util/util_unix.go @@ -3,31 +3,71 @@ package util import ( + "fmt" "os" "os/exec" + "strings" "syscall" "golang.org/x/sys/unix" ) -// ExecCommand executes the given command with $SHELL -func ExecCommand(command string, setpgid bool) *exec.Cmd { +type Executor struct { + shell string + args []string + escaper *strings.Replacer +} + +func NewExecutor(withShell string) *Executor { shell := os.Getenv("SHELL") - if len(shell) == 0 { - shell = "sh" + args := strings.Fields(withShell) + if len(args) > 0 { + shell = args[0] + args = args[1:] + } else { + if len(shell) == 0 { + shell = "sh" + } + args = []string{"-c"} } - return ExecCommandWith(shell, command, setpgid) + + var escaper *strings.Replacer + tokens := strings.Split(shell, "/") + if tokens[len(tokens)-1] == "fish" { + // https://fishshell.com/docs/current/language.html#quotes + // > The only meaningful escape sequences in single quotes are \', which + // > escapes a single quote and \\, which escapes the backslash symbol. + escaper = strings.NewReplacer("\\", "\\\\", "'", "\\'") + } else { + escaper = strings.NewReplacer("'", "'\\''") + } + return &Executor{shell, args, escaper} } -// ExecCommandWith executes the given command with the specified shell -func ExecCommandWith(shell string, command string, setpgid bool) *exec.Cmd { - cmd := exec.Command(shell, "-c", command) +// ExecCommand executes the given command with $SHELL +func (x *Executor) ExecCommand(command string, setpgid bool) *exec.Cmd { + cmd := exec.Command(x.shell, append(x.args, command)...) if setpgid { cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} } return cmd } +func (x *Executor) QuoteEntry(entry string) string { + return "'" + x.escaper.Replace(entry) + "'" +} + +func (x *Executor) Become(stdin *os.File, environ []string, command string) { + shellPath, err := exec.LookPath(x.shell) + if err != nil { + fmt.Fprintf(os.Stderr, "fzf (become): %s\n", err.Error()) + Exit(127) + } + args := append([]string{shellPath}, append(x.args, command)...) + SetStdin(stdin) + syscall.Exec(shellPath, args, environ) +} + // KillCommand kills the process for the given command func KillCommand(cmd *exec.Cmd) error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) diff --git a/src/util/util_windows.go b/src/util/util_windows.go index aa69b99d..cbaa8ce0 100644 --- a/src/util/util_windows.go +++ b/src/util/util_windows.go @@ -6,60 +6,102 @@ import ( "fmt" "os" "os/exec" + "regexp" "strings" "sync/atomic" "syscall" ) -var shellPath atomic.Value +type Executor struct { + shell string + args []string + shellPath atomic.Value +} + +func NewExecutor(withShell string) *Executor { + shell := os.Getenv("SHELL") + args := strings.Fields(withShell) + if len(args) > 0 { + shell = args[0] + } else if len(shell) == 0 { + shell = "cmd" + } + + if len(args) > 0 { + args = args[1:] + } else if strings.Contains(shell, "cmd") { + args = []string{"/v:on/s/c"} + } else if strings.Contains(shell, "pwsh") || strings.Contains(shell, "powershell") { + args = []string{"-NoProfile", "-Command"} + } else { + args = []string{"-c"} + } + return &Executor{shell: shell, args: args} +} // ExecCommand executes the given command with $SHELL -func ExecCommand(command string, setpgid bool) *exec.Cmd { - var shell string - if cached := shellPath.Load(); cached != nil { +// FIXME: setpgid is unused. We set it in the Unix implementation so that we +// can kill preview process with its child processes at once. +// NOTE: For "powershell", we should ideally set output encoding to UTF8, +// but it is left as is now because no adverse effect has been observed. +func (x *Executor) ExecCommand(command string, setpgid bool) *exec.Cmd { + shell := x.shell + if cached := x.shellPath.Load(); cached != nil { shell = cached.(string) } else { - shell = os.Getenv("SHELL") - if len(shell) == 0 { - shell = "cmd" - } else if strings.Contains(shell, "/") { + if strings.Contains(shell, "/") { out, err := exec.Command("cygpath", "-w", shell).Output() if err == nil { shell = strings.Trim(string(out), "\n") } } - shellPath.Store(shell) + x.shellPath.Store(shell) } - return ExecCommandWith(shell, command, setpgid) + cmd := exec.Command(shell, append(x.args, command)...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: false, + CreationFlags: 0, + } + return cmd } -// ExecCommandWith executes the given command with the specified shell -// FIXME: setpgid is unused. We set it in the Unix implementation so that we -// can kill preview process with its child processes at once. -// NOTE: For "powershell", we should ideally set output encoding to UTF8, -// but it is left as is now because no adverse effect has been observed. -func ExecCommandWith(shell string, command string, setpgid bool) *exec.Cmd { - var cmd *exec.Cmd - if strings.Contains(shell, "cmd") { - cmd = exec.Command(shell) - cmd.SysProcAttr = &syscall.SysProcAttr{ - HideWindow: false, - CmdLine: fmt.Sprintf(` /v:on/s/c "%s"`, command), - CreationFlags: 0, +func (x *Executor) Become(stdin *os.File, environ []string, command string) { + cmd := x.ExecCommand(command, false) + cmd.Stdin = stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = environ + err := cmd.Start() + if err != nil { + fmt.Fprintf(os.Stderr, "fzf (become): %s\n", err.Error()) + Exit(127) + } + err = cmd.Wait() + if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + Exit(exitError.ExitCode()) } - return cmd } + Exit(0) +} - if strings.Contains(shell, "pwsh") || strings.Contains(shell, "powershell") { - cmd = exec.Command(shell, "-NoProfile", "-Command", command) +func (x *Executor) QuoteEntry(entry string) string { + if strings.Contains(x.shell, "cmd") { + // backslash escaping is done here for applications + // (see ripgrep test case in terminal_test.go#TestWindowsCommands) + escaped := strings.Replace(entry, `\`, `\\`, -1) + escaped = `"` + strings.Replace(escaped, `"`, `\"`, -1) + `"` + // caret is the escape character for cmd shell + r, _ := regexp.Compile(`[&|<>()@^%!"]`) + return r.ReplaceAllStringFunc(escaped, func(match string) string { + return "^" + match + }) + } else if strings.Contains(x.shell, "pwsh") || strings.Contains(x.shell, "powershell") { + escaped := strings.Replace(entry, `"`, `\"`, -1) + return "'" + strings.Replace(escaped, "'", "''", -1) + "'" } else { - cmd = exec.Command(shell, "-c", command) - } - cmd.SysProcAttr = &syscall.SysProcAttr{ - HideWindow: false, - CreationFlags: 0, + return "'" + strings.Replace(entry, "'", "'\\''", -1) + "'" } - return cmd } // KillCommand kills the process for the given command diff --git a/test/test_go.rb b/test/test_go.rb index b08ac72b..f58b789e 100755 --- a/test/test_go.rb +++ b/test/test_go.rb @@ -1974,7 +1974,7 @@ class TestGoFZF < TestBase tmux.until { |lines| assert_equal 10, lines.item_count } end - def test_reload_should_terminate_stadard_input_stream + def test_reload_should_terminate_standard_input_stream tmux.send_keys %(ruby -e "STDOUT.sync = true; loop { puts 1; sleep 0.1 }" | fzf --bind 'start:reload(seq 100)'), :Enter tmux.until { |lines| assert_equal 100, lines.item_count } end From 2665580120ba1c408016d2b542af9a4229e627ad Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sat, 27 Apr 2024 18:56:23 +0900 Subject: [PATCH 5/6] Add $FZF_POS environment variable Close #2175 Close #3753 --- CHANGELOG.md | 7 +++++++ man/man1/fzf.1 | 2 ++ src/terminal.go | 1 + test/test_go.rb | 11 +++++++++++ 4 files changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 920bceff..c550c173 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ CHANGELOG 0.51.0 ------ +- Added a new environment variable `$FZF_POS` exported to the child processes. It's the vertical position of the cursor in the list starting from 1. + ```sh + # Toggle selection to the top or to the bottom + seq 30 | fzf --multi --bind 'load:pos(10)' \ + --bind 'shift-up:transform:for _ in $(seq $FZF_POS $FZF_MATCH_COUNT); do echo -n +toggle-up; done' \ + --bind 'shift-down:transform:for _ in $(seq 1 $FZF_POS); do echo -n +toggle-down; done' + ``` - Added `--with-shell` option to start child processes with a custom shell command and flags ```sh gem list | fzf --with-shell 'ruby -e' \ diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 742fba5a..8e79c2e4 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -984,6 +984,8 @@ fzf exports the following environment variables to its child processes. .br .BR FZF_SELECT_COUNT " Number of selected items" .br +.BR FZF_POS " Vertical position of the cursor in the list starting from 1" +.br .BR FZF_QUERY " Current query string" .br .BR FZF_PROMPT " Prompt string" diff --git a/src/terminal.go b/src/terminal.go index 8d114e1b..e9ec363b 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -856,6 +856,7 @@ func (t *Terminal) environ() []string { env = append(env, fmt.Sprintf("FZF_SELECT_COUNT=%d", len(t.selected))) env = append(env, fmt.Sprintf("FZF_LINES=%d", t.areaLines)) env = append(env, fmt.Sprintf("FZF_COLUMNS=%d", t.areaColumns)) + env = append(env, fmt.Sprintf("FZF_POS=%d", util.Min(t.merger.Length(), t.cy+1))) return env } diff --git a/test/test_go.rb b/test/test_go.rb index f58b789e..2f93403c 100755 --- a/test/test_go.rb +++ b/test/test_go.rb @@ -3250,6 +3250,17 @@ class TestGoFZF < TestBase tmux.send_keys :Up tmux.until { |lines| assert_includes lines, '> 2' } end + + def test_fzf_pos + tmux.send_keys "seq 100 | #{FZF} --preview 'echo $FZF_POS / $FZF_MATCH_COUNT'", :Enter + tmux.until { |lines| assert(lines.any? { |line| line.include?('1 / 100') }) } + tmux.send_keys :Up + tmux.until { |lines| assert(lines.any? { |line| line.include?('2 / 100') }) } + tmux.send_keys '99' + tmux.until { |lines| assert(lines.any? { |line| line.include?('1 / 1') }) } + tmux.send_keys '99' + tmux.until { |lines| assert(lines.any? { |line| line.include?('0 / 0') }) } + end end module TestShell From 4a68eac99bb3814f912d236fa9356516ebda2fa5 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sat, 27 Apr 2024 19:04:30 +0900 Subject: [PATCH 6/6] Suggest using toggle+up instead of toggle-up --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c550c173..911f043a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,8 @@ CHANGELOG ```sh # Toggle selection to the top or to the bottom seq 30 | fzf --multi --bind 'load:pos(10)' \ - --bind 'shift-up:transform:for _ in $(seq $FZF_POS $FZF_MATCH_COUNT); do echo -n +toggle-up; done' \ - --bind 'shift-down:transform:for _ in $(seq 1 $FZF_POS); do echo -n +toggle-down; done' + --bind 'shift-up:transform:for _ in $(seq $FZF_POS $FZF_MATCH_COUNT); do echo -n +toggle+up; done' \ + --bind 'shift-down:transform:for _ in $(seq 1 $FZF_POS); do echo -n +toggle+down; done' ``` - Added `--with-shell` option to start child processes with a custom shell command and flags ```sh