summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJunegunn Choi <junegunn.c@gmail.com>2025-06-19 22:35:23 +0900
committerJunegunn Choi <junegunn.c@gmail.com>2025-06-19 22:35:23 +0900
commitdcec6354f5e57be1f8420850c3cfd6a56206c67a (patch)
tree098e183bbad04c6d4babbfb16bc9557e7d8fecb6
parent16d338da84dda718935c46cbbc54c8c3be9e21a2 (diff)
downloadfzf-dcec6354f5e57be1f8420850c3cfd6a56206c67a.tar.gz
Add {*} placeholder flag
-rw-r--r--CHANGELOG.md5
-rw-r--r--man/man1/fzf.112
-rw-r--r--src/terminal.go87
-rw-r--r--src/terminal_test.go69
-rw-r--r--test/test_preview.rb14
5 files changed, 112 insertions, 75 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4a8cb3a7..6bfb9427 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,11 @@ CHANGELOG
0.63.0
------
+- Added `{*}` placeholder flag that evaluates to all matched items.
+ ```bash
+ seq 10000 | fzf --preview "awk '{sum += \$1} END {print sum}' {*f}"
+ ```
+ - Use this with caution, as it can make fzf sluggish for large lists.
- Added background variants of transform actions with `bg-` prefix that run asynchronously in the background
```sh
GETTER='curl -s http://metaphorpsum.com/sentences/1'
diff --git a/man/man1/fzf.1 b/man/man1/fzf.1
index a64b5e0d..df2fba5f 100644
--- a/man/man1/fzf.1
+++ b/man/man1/fzf.1
@@ -789,13 +789,16 @@ fzf also exports \fB$FZF_PREVIEW_TOP\fR and \fB$FZF_PREVIEW_LEFT\fR so that
the preview command can determine the position of the preview window.
A placeholder expression starting with \fB+\fR flag will be replaced to the
-space-separated list of the selected lines (or the current line if no selection
+space-separated list of the selected items (or the current item if no selection
was made) individually quoted.
e.g.
\fBfzf \-\-multi \-\-preview='head \-10 {+}'
git log \-\-oneline | fzf \-\-multi \-\-preview 'git show {+1}'\fR
+Similarly, a placeholder expression starting with \fB*\fR flag will be replaced
+to the space-separated list of all matched items individually quoted.
+
Each expression expands to a quoted string, so that it's safe to pass it as an
argument to an external command. So you should not manually add quotes around
the curly braces. But if you don't want this behavior, you can put
@@ -807,14 +810,13 @@ from the replacement string. To preserve the whitespace, use the \fBs\fR flag.
A placeholder expression with \fBf\fR flag is replaced to the path of
a temporary file that holds the evaluated list. This is useful when you
-multi-select a large number of items and the length of the evaluated string may
+pass a large number of items and the length of the evaluated string may
exceed \fBARG_MAX\fR.
e.g.
- \fB# Press CTRL\-A to select 100K items and see the sum of all the numbers.
+ \fB# See the sum of all the matched numbers
# This won't work properly without 'f' flag due to ARG_MAX limit.
- seq 100000 | fzf \-\-multi \-\-bind ctrl\-a:select\-all \\
- \-\-preview "awk '{sum+=\\$1} END {print sum}' {+f}"\fR
+ seq 100000 | fzf \-\-preview "awk '{sum+=\\$1} END {print sum}' {*f}"\fR
Also,
diff --git a/src/terminal.go b/src/terminal.go
index 85a51112..231375ba 100644
--- a/src/terminal.go
+++ b/src/terminal.go
@@ -66,7 +66,7 @@ const maxFocusEvents = 10000
const blockDuration = 1 * time.Second
func init() {
- placeholder = regexp.MustCompile(`\\?(?:{[+sfr]*[0-9,-.]*}|{q(?::s?[0-9,-.]+)?}|{fzf:(?:query|action|prompt)}|{\+?f?nf?})`)
+ placeholder = regexp.MustCompile(`\\?(?:{[+*sfr]*[0-9,-.]*}|{q(?::s?[0-9,-.]+)?}|{fzf:(?:query|action|prompt)}|{\+?f?nf?})`)
whiteSuffix = regexp.MustCompile(`\s*$`)
offsetComponentRegex = regexp.MustCompile(`([+-][0-9]+)|(-?/[1-9][0-9]*)`)
offsetTrimCharsRegex = regexp.MustCompile(`[^0-9/+-]`)
@@ -692,6 +692,7 @@ func processExecution(action actionType) bool {
type placeholderFlags struct {
plus bool
+ asterisk bool
preserveSpace bool
number bool
forceUpdate bool
@@ -713,7 +714,7 @@ type searchRequest struct {
type previewRequest struct {
template string
scrollOffset int
- list []*Item
+ list [3][]*Item // current, select, and all matched items
env []string
query string
}
@@ -4099,6 +4100,8 @@ func parsePlaceholder(match string) (bool, string, placeholderFlags) {
trimmed := ""
for _, char := range match[1:] {
switch char {
+ case '*':
+ flags.asterisk = true
case '+':
flags.plus = true
case 's':
@@ -4122,19 +4125,16 @@ func parsePlaceholder(match string) (bool, string, placeholderFlags) {
return false, matchWithoutFlags, flags
}
-func hasPreviewFlags(template string) (slot bool, plus bool, forceUpdate bool) {
+func hasPreviewFlags(template string) (slot bool, plus bool, asterisk bool, forceUpdate bool) {
for _, match := range placeholder.FindAllString(template, -1) {
escaped, _, flags := parsePlaceholder(match)
if escaped {
continue
}
- if flags.plus {
- plus = true
- }
- if flags.forceUpdate {
- forceUpdate = true
- }
slot = true
+ plus = plus || flags.plus
+ asterisk = asterisk || flags.asterisk
+ forceUpdate = forceUpdate || flags.forceUpdate
}
return
}
@@ -4146,17 +4146,17 @@ type replacePlaceholderParams struct {
printsep string
forcePlus bool
query string
- allItems []*Item
+ allItems [3][]*Item // current, select, and all matched items
lastAction actionType
prompt string
executor *util.Executor
}
func (t *Terminal) replacePlaceholderInInitialCommand(template string) (string, []string) {
- return t.replacePlaceholder(template, false, string(t.input), []*Item{nil, nil})
+ return t.replacePlaceholder(template, false, string(t.input), [3][]*Item{nil, nil, nil})
}
-func (t *Terminal) replacePlaceholder(template string, forcePlus bool, input string, list []*Item) (string, []string) {
+func (t *Terminal) replacePlaceholder(template string, forcePlus bool, input string, list [3][]*Item) (string, []string) {
return replacePlaceholder(replacePlaceholderParams{
template: template,
stripAnsi: t.ansi,
@@ -4177,7 +4177,11 @@ func (t *Terminal) evaluateScrollOffset() int {
}
// We only need the current item to calculate the scroll offset
- replaced, tempFiles := t.replacePlaceholder(t.activePreviewOpts.scroll, false, "", []*Item{t.currentItem(), nil})
+ current := []*Item{t.currentItem()}
+ if current[0] == nil {
+ current = nil
+ }
+ replaced, tempFiles := t.replacePlaceholder(t.activePreviewOpts.scroll, false, "", [3][]*Item{current, nil, nil})
removeFiles(tempFiles)
offsetExpr := offsetTrimCharsRegex.ReplaceAllString(replaced, "")
@@ -4209,14 +4213,9 @@ func (t *Terminal) evaluateScrollOffset() int {
func replacePlaceholder(params replacePlaceholderParams) (string, []string) {
tempFiles := []string{}
- current := params.allItems[:1]
- selected := params.allItems[1:]
- if current[0] == nil {
- current = []*Item{}
- }
- if selected[0] == nil {
- selected = []*Item{}
- }
+ current := params.allItems[0]
+ selected := params.allItems[1]
+ matched := params.allItems[2]
// replace placeholders one by one
replaced := placeholder.ReplaceAllStringFunc(params.template, func(match string) string {
@@ -4312,7 +4311,9 @@ func replacePlaceholder(params replacePlaceholderParams) (string, []string) {
// apply 'replace' function over proper set of items and return result
items := current
- if flags.plus || params.forcePlus {
+ if flags.asterisk {
+ items = matched
+ } else if flags.plus || params.forcePlus {
items = selected
}
replacements := make([]string, len(items))
@@ -4546,11 +4547,15 @@ func (t *Terminal) currentItem() *Item {
return nil
}
-func (t *Terminal) buildPlusList(template string, forcePlus bool) (bool, []*Item) {
+func (t *Terminal) buildPlusList(template string, forcePlus bool) (bool, [3][]*Item) {
current := t.currentItem()
- slot, plus, forceUpdate := hasPreviewFlags(template)
- if !(!slot || forceUpdate || (forcePlus || plus) && len(t.selected) > 0) {
- return current != nil, []*Item{current, current}
+ slot, plus, asterisk, forceUpdate := hasPreviewFlags(template)
+ if !(!slot || forceUpdate || asterisk || (forcePlus || plus) && len(t.selected) > 0) {
+ if current == nil {
+ // Invalid
+ return false, [3][]*Item{nil, nil, nil}
+ }
+ return true, [3][]*Item{{current}, {current}, nil}
}
// We would still want to update preview window even if there is no match if
@@ -4561,17 +4566,25 @@ func (t *Terminal) buildPlusList(template string, forcePlus bool) (bool, []*Item
current = &minItem
}
+ var all []*Item
+ if asterisk {
+ cnt := t.merger.Length()
+ all = make([]*Item, cnt)
+ for i := 0; i < cnt; i++ {
+ all[i] = t.merger.Get(i).item
+ }
+ }
+
var sels []*Item
if len(t.selected) == 0 {
- sels = []*Item{current, current}
- } else {
- sels = make([]*Item, len(t.selected)+1)
- sels[0] = current
+ sels = []*Item{current}
+ } else if len(t.selected) > 0 {
+ sels = make([]*Item, len(t.selected))
for i, sel := range t.sortSelected() {
- sels[i+1] = sel.item
+ sels[i] = sel.item
}
}
- return true, sels
+ return true, [3][]*Item{{current}, sels, all}
}
func (t *Terminal) selectItem(item *Item) bool {
@@ -4831,7 +4844,8 @@ func (t *Terminal) Loop() error {
stop := false
t.previewBox.WaitFor(reqPreviewReady)
for {
- var items []*Item
+ requested := false
+ var items [3][]*Item
var commandTemplate string
var env []string
var query string
@@ -4849,6 +4863,7 @@ func (t *Terminal) Loop() error {
items = request.list
env = request.env
query = request.query
+ requested = true
}
}
events.Clear()
@@ -4856,7 +4871,7 @@ func (t *Terminal) Loop() error {
if stop {
break
}
- if items == nil {
+ if !requested {
continue
}
version++
@@ -6396,7 +6411,7 @@ func (t *Terminal) Loop() error {
// We run the command even when there's no match
// 1. If the template doesn't have any slots
// 2. If the template has {q}
- slot, _, forceUpdate := hasPreviewFlags(a.a)
+ slot, _, _, forceUpdate := hasPreviewFlags(a.a)
valid = !slot || forceUpdate
}
if valid {
@@ -6585,7 +6600,7 @@ func (t *Terminal) Loop() error {
}
if queryChanged && t.canPreview() && len(t.previewOpts.command) > 0 {
- _, _, forceUpdate := hasPreviewFlags(t.previewOpts.command)
+ _, _, _, forceUpdate := hasPreviewFlags(t.previewOpts.command)
if forceUpdate {
t.version++
}
diff --git a/src/terminal_test.go b/src/terminal_test.go
index 380e40d1..d6a49138 100644
--- a/src/terminal_test.go
+++ b/src/terminal_test.go
@@ -12,7 +12,7 @@ import (
"github.com/junegunn/fzf/src/util"
)
-func replacePlaceholderTest(template string, stripAnsi bool, delimiter Delimiter, printsep string, forcePlus bool, query string, allItems []*Item) string {
+func replacePlaceholderTest(template string, stripAnsi bool, delimiter Delimiter, printsep string, forcePlus bool, query string, allItems [3][]*Item) string {
replaced, _ := replacePlaceholder(replacePlaceholderParams{
template: template,
stripAnsi: stripAnsi,
@@ -30,11 +30,11 @@ func replacePlaceholderTest(template string, stripAnsi bool, delimiter Delimiter
func TestReplacePlaceholder(t *testing.T) {
item1 := newItem(" foo'bar \x1b[31mbaz\x1b[m")
- items1 := []*Item{item1, item1}
- items2 := []*Item{
- newItem("foo'bar \x1b[31mbaz\x1b[m"),
- newItem("foo'bar \x1b[31mbaz\x1b[m"),
- newItem("FOO'BAR \x1b[31mBAZ\x1b[m")}
+ items1 := [3][]*Item{{item1}, {item1}, nil}
+ items2 := [3][]*Item{
+ {newItem("foo'bar \x1b[31mbaz\x1b[m")},
+ {newItem("foo'bar \x1b[31mbaz\x1b[m"),
+ newItem("FOO'BAR \x1b[31mBAZ\x1b[m")}, nil}
delim := "'"
var regex *regexp.Regexp
@@ -145,11 +145,11 @@ func TestReplacePlaceholder(t *testing.T) {
checkFormat("echo {{.O}} {{.O}}")
// No match
- result = replacePlaceholderTest("echo {}/{+}", true, Delimiter{}, printsep, false, "query", []*Item{nil, nil})
+ result = replacePlaceholderTest("echo {}/{+}", true, Delimiter{}, printsep, false, "query", [3][]*Item{nil, nil, nil})
check("echo /")
// No match, but with selections
- result = replacePlaceholderTest("echo {}/{+}", true, Delimiter{}, printsep, false, "query", []*Item{nil, item1})
+ result = replacePlaceholderTest("echo {}/{+}", true, Delimiter{}, printsep, false, "query", [3][]*Item{nil, {item1}, nil})
checkFormat("echo /{{.O}} foo{{.I}}bar baz{{.O}}")
// String delimiter
@@ -166,17 +166,18 @@ func TestReplacePlaceholder(t *testing.T) {
Test single placeholders, but focus on the placeholders' parameters (e.g. flags).
see: TestParsePlaceholder
*/
- items3 := []*Item{
+ items3 := [3][]*Item{
// single line
- newItem("1a 1b 1c 1d 1e 1f"),
+ {newItem("1a 1b 1c 1d 1e 1f")},
// multi line
- newItem("1a 1b 1c 1d 1e 1f"),
- newItem("2a 2b 2c 2d 2e 2f"),
- newItem("3a 3b 3c 3d 3e 3f"),
- newItem("4a 4b 4c 4d 4e 4f"),
- newItem("5a 5b 5c 5d 5e 5f"),
- newItem("6a 6b 6c 6d 6e 6f"),
- newItem("7a 7b 7c 7d 7e 7f"),
+ {newItem("1a 1b 1c 1d 1e 1f"),
+ newItem("2a 2b 2c 2d 2e 2f"),
+ newItem("3a 3b 3c 3d 3e 3f"),
+ newItem("4a 4b 4c 4d 4e 4f"),
+ newItem("5a 5b 5c 5d 5e 5f"),
+ newItem("6a 6b 6c 6d 6e 6f"),
+ newItem("7a 7b 7c 7d 7e 7f")},
+ nil,
}
stripAnsi := false
forcePlus := false
@@ -557,14 +558,14 @@ func newItem(str string) *Item {
return &Item{origText: &bytes, text: util.ToChars([]byte(trimmed))}
}
-// Functions tested in this file require array of items (allItems). The array needs
-// to consist of at least two nils. This is helper function.
-func newItems(str ...string) []*Item {
- result := make([]*Item, util.Max(len(str), 2))
+// Functions tested in this file require array of items (allItems).
+// This is helper function.
+func newItems(str ...string) [3][]*Item {
+ result := make([]*Item, len(str))
for i, s := range str {
result[i] = newItem(s)
}
- return result
+ return [3][]*Item{result, nil, nil}
}
// (for logging purposes)
@@ -588,7 +589,7 @@ func templateToString(format string, data any) string {
type give struct {
template string
query string
- allItems []*Item
+ allItems [3][]*Item
}
type want struct {
/*
@@ -626,25 +627,25 @@ func testCommands(t *testing.T, tests []testCase) {
// evaluate the test cases
for idx, test := range tests {
gotOutput := replacePlaceholderTest(
- test.give.template, stripAnsi, delimiter, printsep, forcePlus,
- test.give.query,
- test.give.allItems)
+ test.template, stripAnsi, delimiter, printsep, forcePlus,
+ test.query,
+ test.allItems)
switch {
- case test.want.output != "":
- if gotOutput != test.want.output {
+ case test.output != "":
+ if gotOutput != test.output {
t.Errorf("tests[%v]:\ngave{\n\ttemplate: '%s',\n\tquery: '%s',\n\tallItems: %s}\nand got '%s',\nbut want '%s'",
idx,
- test.give.template, test.give.query, test.give.allItems,
- gotOutput, test.want.output)
+ test.template, test.query, test.allItems,
+ gotOutput, test.output)
}
- case test.want.match != "":
- wantMatch := strings.ReplaceAll(test.want.match, `\`, `\\`)
+ case test.match != "":
+ wantMatch := strings.ReplaceAll(test.match, `\`, `\\`)
wantRegex := regexp.MustCompile(wantMatch)
if !wantRegex.MatchString(gotOutput) {
t.Errorf("tests[%v]:\ngave{\n\ttemplate: '%s',\n\tquery: '%s',\n\tallItems: %s}\nand got '%s',\nbut want '%s'",
idx,
- test.give.template, test.give.query, test.give.allItems,
- gotOutput, test.want.match)
+ test.template, test.query, test.allItems,
+ gotOutput, test.match)
}
default:
t.Errorf("tests[%v]: test case does not describe 'want' property", idx)
diff --git a/test/test_preview.rb b/test/test_preview.rb
index 576e36ec..fa05fbcb 100644
--- a/test/test_preview.rb
+++ b/test/test_preview.rb
@@ -189,6 +189,20 @@ class TestPreview < TestInteractive
tmux.until { |lines| assert_includes lines[1], ' {//1 10/1 10 /123//0 9} ' }
end
+ def test_preview_asterisk
+ tmux.send_keys %(seq 5 | #{FZF} --multi --preview 'echo [{} / {+} / {*}]' --preview-window '+{1}'), :Enter
+ tmux.until { |lines| assert_equal 5, lines.match_count }
+ tmux.until { |lines| assert_includes lines[1], ' [1 / 1 / 1 2 3 4 5] ' }
+ tmux.send_keys :BTab
+ tmux.until { |lines| assert_includes lines[1], ' [2 / 1 / 1 2 3 4 5] ' }
+ tmux.send_keys :BTab
+ tmux.until { |lines| assert_includes lines[1], ' [3 / 1 2 / 1 2 3 4 5] ' }
+ tmux.send_keys '5'
+ tmux.until { |lines| assert_includes lines[1], ' [5 / 1 2 / 5] ' }
+ tmux.send_keys '5'
+ tmux.until { |lines| assert_includes lines[1], ' [ / 1 2 / ] ' }
+ end
+
def test_preview_file
tmux.send_keys %[(echo foo bar; echo bar foo) | #{FZF} --multi --preview 'cat {+f} {+f2} {+nf} {+fn}' --print0], :Enter
tmux.until { |lines| assert_includes lines[1], ' foo barbar00 ' }