Add {*} placeholder flag

This commit is contained in:
Junegunn Choi
2025-06-19 22:35:23 +09:00
parent 16d338da84
commit dcec6354f5
5 changed files with 115 additions and 78 deletions

View File

@@ -3,6 +3,11 @@ CHANGELOG
0.63.0 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 - Added background variants of transform actions with `bg-` prefix that run asynchronously in the background
```sh ```sh
GETTER='curl -s http://metaphorpsum.com/sentences/1' GETTER='curl -s http://metaphorpsum.com/sentences/1'

View File

@@ -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. the preview command can determine the position of the preview window.
A placeholder expression starting with \fB+\fR flag will be replaced to the 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. was made) individually quoted.
e.g. e.g.
\fBfzf \-\-multi \-\-preview='head \-10 {+}' \fBfzf \-\-multi \-\-preview='head \-10 {+}'
git log \-\-oneline | fzf \-\-multi \-\-preview 'git show {+1}'\fR 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 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 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 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 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 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. exceed \fBARG_MAX\fR.
e.g. 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. # This won't work properly without 'f' flag due to ARG_MAX limit.
seq 100000 | fzf \-\-multi \-\-bind ctrl\-a:select\-all \\ seq 100000 | fzf \-\-preview "awk '{sum+=\\$1} END {print sum}' {*f}"\fR
\-\-preview "awk '{sum+=\\$1} END {print sum}' {+f}"\fR
Also, Also,

View File

@@ -66,7 +66,7 @@ const maxFocusEvents = 10000
const blockDuration = 1 * time.Second const blockDuration = 1 * time.Second
func init() { 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*$`) whiteSuffix = regexp.MustCompile(`\s*$`)
offsetComponentRegex = regexp.MustCompile(`([+-][0-9]+)|(-?/[1-9][0-9]*)`) offsetComponentRegex = regexp.MustCompile(`([+-][0-9]+)|(-?/[1-9][0-9]*)`)
offsetTrimCharsRegex = regexp.MustCompile(`[^0-9/+-]`) offsetTrimCharsRegex = regexp.MustCompile(`[^0-9/+-]`)
@@ -692,6 +692,7 @@ func processExecution(action actionType) bool {
type placeholderFlags struct { type placeholderFlags struct {
plus bool plus bool
asterisk bool
preserveSpace bool preserveSpace bool
number bool number bool
forceUpdate bool forceUpdate bool
@@ -713,7 +714,7 @@ type searchRequest struct {
type previewRequest struct { type previewRequest struct {
template string template string
scrollOffset int scrollOffset int
list []*Item list [3][]*Item // current, select, and all matched items
env []string env []string
query string query string
} }
@@ -4099,6 +4100,8 @@ func parsePlaceholder(match string) (bool, string, placeholderFlags) {
trimmed := "" trimmed := ""
for _, char := range match[1:] { for _, char := range match[1:] {
switch char { switch char {
case '*':
flags.asterisk = true
case '+': case '+':
flags.plus = true flags.plus = true
case 's': case 's':
@@ -4122,19 +4125,16 @@ func parsePlaceholder(match string) (bool, string, placeholderFlags) {
return false, matchWithoutFlags, flags 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) { for _, match := range placeholder.FindAllString(template, -1) {
escaped, _, flags := parsePlaceholder(match) escaped, _, flags := parsePlaceholder(match)
if escaped { if escaped {
continue continue
} }
if flags.plus {
plus = true
}
if flags.forceUpdate {
forceUpdate = true
}
slot = true slot = true
plus = plus || flags.plus
asterisk = asterisk || flags.asterisk
forceUpdate = forceUpdate || flags.forceUpdate
} }
return return
} }
@@ -4146,17 +4146,17 @@ type replacePlaceholderParams struct {
printsep string printsep string
forcePlus bool forcePlus bool
query string query string
allItems []*Item allItems [3][]*Item // current, select, and all matched items
lastAction actionType lastAction actionType
prompt string prompt string
executor *util.Executor executor *util.Executor
} }
func (t *Terminal) replacePlaceholderInInitialCommand(template string) (string, []string) { 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{ return replacePlaceholder(replacePlaceholderParams{
template: template, template: template,
stripAnsi: t.ansi, stripAnsi: t.ansi,
@@ -4177,7 +4177,11 @@ func (t *Terminal) evaluateScrollOffset() int {
} }
// We only need the current item to calculate the scroll offset // 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) removeFiles(tempFiles)
offsetExpr := offsetTrimCharsRegex.ReplaceAllString(replaced, "") offsetExpr := offsetTrimCharsRegex.ReplaceAllString(replaced, "")
@@ -4209,14 +4213,9 @@ func (t *Terminal) evaluateScrollOffset() int {
func replacePlaceholder(params replacePlaceholderParams) (string, []string) { func replacePlaceholder(params replacePlaceholderParams) (string, []string) {
tempFiles := []string{} tempFiles := []string{}
current := params.allItems[:1] current := params.allItems[0]
selected := params.allItems[1:] selected := params.allItems[1]
if current[0] == nil { matched := params.allItems[2]
current = []*Item{}
}
if selected[0] == nil {
selected = []*Item{}
}
// replace placeholders one by one // replace placeholders one by one
replaced := placeholder.ReplaceAllStringFunc(params.template, func(match string) string { 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 // apply 'replace' function over proper set of items and return result
items := current items := current
if flags.plus || params.forcePlus { if flags.asterisk {
items = matched
} else if flags.plus || params.forcePlus {
items = selected items = selected
} }
replacements := make([]string, len(items)) replacements := make([]string, len(items))
@@ -4546,11 +4547,15 @@ func (t *Terminal) currentItem() *Item {
return nil 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() current := t.currentItem()
slot, plus, forceUpdate := hasPreviewFlags(template) slot, plus, asterisk, forceUpdate := hasPreviewFlags(template)
if !(!slot || forceUpdate || (forcePlus || plus) && len(t.selected) > 0) { if !(!slot || forceUpdate || asterisk || (forcePlus || plus) && len(t.selected) > 0) {
return current != nil, []*Item{current, current} 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 // 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 current = &minItem
} }
var sels []*Item var all []*Item
if len(t.selected) == 0 { if asterisk {
sels = []*Item{current, current} cnt := t.merger.Length()
} else { all = make([]*Item, cnt)
sels = make([]*Item, len(t.selected)+1) for i := 0; i < cnt; i++ {
sels[0] = current all[i] = t.merger.Get(i).item
for i, sel := range t.sortSelected() {
sels[i+1] = sel.item
} }
} }
return true, sels
var sels []*Item
if len(t.selected) == 0 {
sels = []*Item{current}
} else if len(t.selected) > 0 {
sels = make([]*Item, len(t.selected))
for i, sel := range t.sortSelected() {
sels[i] = sel.item
}
}
return true, [3][]*Item{{current}, sels, all}
} }
func (t *Terminal) selectItem(item *Item) bool { func (t *Terminal) selectItem(item *Item) bool {
@@ -4831,7 +4844,8 @@ func (t *Terminal) Loop() error {
stop := false stop := false
t.previewBox.WaitFor(reqPreviewReady) t.previewBox.WaitFor(reqPreviewReady)
for { for {
var items []*Item requested := false
var items [3][]*Item
var commandTemplate string var commandTemplate string
var env []string var env []string
var query string var query string
@@ -4849,6 +4863,7 @@ func (t *Terminal) Loop() error {
items = request.list items = request.list
env = request.env env = request.env
query = request.query query = request.query
requested = true
} }
} }
events.Clear() events.Clear()
@@ -4856,7 +4871,7 @@ func (t *Terminal) Loop() error {
if stop { if stop {
break break
} }
if items == nil { if !requested {
continue continue
} }
version++ version++
@@ -6396,7 +6411,7 @@ func (t *Terminal) Loop() error {
// We run the command even when there's no match // We run the command even when there's no match
// 1. If the template doesn't have any slots // 1. If the template doesn't have any slots
// 2. If the template has {q} // 2. If the template has {q}
slot, _, forceUpdate := hasPreviewFlags(a.a) slot, _, _, forceUpdate := hasPreviewFlags(a.a)
valid = !slot || forceUpdate valid = !slot || forceUpdate
} }
if valid { if valid {
@@ -6585,7 +6600,7 @@ func (t *Terminal) Loop() error {
} }
if queryChanged && t.canPreview() && len(t.previewOpts.command) > 0 { if queryChanged && t.canPreview() && len(t.previewOpts.command) > 0 {
_, _, forceUpdate := hasPreviewFlags(t.previewOpts.command) _, _, _, forceUpdate := hasPreviewFlags(t.previewOpts.command)
if forceUpdate { if forceUpdate {
t.version++ t.version++
} }

View File

@@ -12,7 +12,7 @@ import (
"github.com/junegunn/fzf/src/util" "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{ replaced, _ := replacePlaceholder(replacePlaceholderParams{
template: template, template: template,
stripAnsi: stripAnsi, stripAnsi: stripAnsi,
@@ -30,11 +30,11 @@ func replacePlaceholderTest(template string, stripAnsi bool, delimiter Delimiter
func TestReplacePlaceholder(t *testing.T) { func TestReplacePlaceholder(t *testing.T) {
item1 := newItem(" foo'bar \x1b[31mbaz\x1b[m") item1 := newItem(" foo'bar \x1b[31mbaz\x1b[m")
items1 := []*Item{item1, item1} items1 := [3][]*Item{{item1}, {item1}, nil}
items2 := []*Item{ items2 := [3][]*Item{
newItem("foo'bar \x1b[31mbaz\x1b[m"), {newItem("foo'bar \x1b[31mbaz\x1b[m")},
newItem("foo'bar \x1b[31mbaz\x1b[m"), {newItem("foo'bar \x1b[31mbaz\x1b[m"),
newItem("FOO'BAR \x1b[31mBAZ\x1b[m")} newItem("FOO'BAR \x1b[31mBAZ\x1b[m")}, nil}
delim := "'" delim := "'"
var regex *regexp.Regexp var regex *regexp.Regexp
@@ -145,11 +145,11 @@ func TestReplacePlaceholder(t *testing.T) {
checkFormat("echo {{.O}} {{.O}}") checkFormat("echo {{.O}} {{.O}}")
// No match // 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 /") check("echo /")
// No match, but with selections // 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}}") checkFormat("echo /{{.O}} foo{{.I}}bar baz{{.O}}")
// String delimiter // String delimiter
@@ -166,17 +166,18 @@ func TestReplacePlaceholder(t *testing.T) {
Test single placeholders, but focus on the placeholders' parameters (e.g. flags). Test single placeholders, but focus on the placeholders' parameters (e.g. flags).
see: TestParsePlaceholder see: TestParsePlaceholder
*/ */
items3 := []*Item{ items3 := [3][]*Item{
// single line // single line
newItem("1a 1b 1c 1d 1e 1f"), {newItem("1a 1b 1c 1d 1e 1f")},
// multi line // multi line
newItem("1a 1b 1c 1d 1e 1f"), {newItem("1a 1b 1c 1d 1e 1f"),
newItem("2a 2b 2c 2d 2e 2f"), newItem("2a 2b 2c 2d 2e 2f"),
newItem("3a 3b 3c 3d 3e 3f"), newItem("3a 3b 3c 3d 3e 3f"),
newItem("4a 4b 4c 4d 4e 4f"), newItem("4a 4b 4c 4d 4e 4f"),
newItem("5a 5b 5c 5d 5e 5f"), newItem("5a 5b 5c 5d 5e 5f"),
newItem("6a 6b 6c 6d 6e 6f"), newItem("6a 6b 6c 6d 6e 6f"),
newItem("7a 7b 7c 7d 7e 7f"), newItem("7a 7b 7c 7d 7e 7f")},
nil,
} }
stripAnsi := false stripAnsi := false
forcePlus := false forcePlus := false
@@ -557,14 +558,14 @@ func newItem(str string) *Item {
return &Item{origText: &bytes, text: util.ToChars([]byte(trimmed))} return &Item{origText: &bytes, text: util.ToChars([]byte(trimmed))}
} }
// Functions tested in this file require array of items (allItems). The array needs // Functions tested in this file require array of items (allItems).
// to consist of at least two nils. This is helper function. // This is helper function.
func newItems(str ...string) []*Item { func newItems(str ...string) [3][]*Item {
result := make([]*Item, util.Max(len(str), 2)) result := make([]*Item, len(str))
for i, s := range str { for i, s := range str {
result[i] = newItem(s) result[i] = newItem(s)
} }
return result return [3][]*Item{result, nil, nil}
} }
// (for logging purposes) // (for logging purposes)
@@ -588,7 +589,7 @@ func templateToString(format string, data any) string {
type give struct { type give struct {
template string template string
query string query string
allItems []*Item allItems [3][]*Item
} }
type want struct { type want struct {
/* /*
@@ -626,25 +627,25 @@ func testCommands(t *testing.T, tests []testCase) {
// evaluate the test cases // evaluate the test cases
for idx, test := range tests { for idx, test := range tests {
gotOutput := replacePlaceholderTest( gotOutput := replacePlaceholderTest(
test.give.template, stripAnsi, delimiter, printsep, forcePlus, test.template, stripAnsi, delimiter, printsep, forcePlus,
test.give.query, test.query,
test.give.allItems) test.allItems)
switch { switch {
case test.want.output != "": case test.output != "":
if gotOutput != test.want.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'", t.Errorf("tests[%v]:\ngave{\n\ttemplate: '%s',\n\tquery: '%s',\n\tallItems: %s}\nand got '%s',\nbut want '%s'",
idx, idx,
test.give.template, test.give.query, test.give.allItems, test.template, test.query, test.allItems,
gotOutput, test.want.output) gotOutput, test.output)
} }
case test.want.match != "": case test.match != "":
wantMatch := strings.ReplaceAll(test.want.match, `\`, `\\`) wantMatch := strings.ReplaceAll(test.match, `\`, `\\`)
wantRegex := regexp.MustCompile(wantMatch) wantRegex := regexp.MustCompile(wantMatch)
if !wantRegex.MatchString(gotOutput) { if !wantRegex.MatchString(gotOutput) {
t.Errorf("tests[%v]:\ngave{\n\ttemplate: '%s',\n\tquery: '%s',\n\tallItems: %s}\nand got '%s',\nbut want '%s'", t.Errorf("tests[%v]:\ngave{\n\ttemplate: '%s',\n\tquery: '%s',\n\tallItems: %s}\nand got '%s',\nbut want '%s'",
idx, idx,
test.give.template, test.give.query, test.give.allItems, test.template, test.query, test.allItems,
gotOutput, test.want.match) gotOutput, test.match)
} }
default: default:
t.Errorf("tests[%v]: test case does not describe 'want' property", idx) t.Errorf("tests[%v]: test case does not describe 'want' property", idx)

View File

@@ -189,6 +189,20 @@ class TestPreview < TestInteractive
tmux.until { |lines| assert_includes lines[1], ' {//1 10/1 10 /123//0 9} ' } tmux.until { |lines| assert_includes lines[1], ' {//1 10/1 10 /123//0 9} ' }
end 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 def test_preview_file
tmux.send_keys %[(echo foo bar; echo bar foo) | #{FZF} --multi --preview 'cat {+f} {+f2} {+nf} {+fn}' --print0], :Enter 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 ' } tmux.until { |lines| assert_includes lines[1], ' foo barbar00 ' }