diff --git a/CHANGELOG.md b/CHANGELOG.md index 7202d1d5..657df7bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,40 @@ CHANGELOG ========= +0.60.0 +------ + +- Added `--accept-nth` for choosing output fields + ```sh + ps -ef | fzf --multi --header-lines 1 | awk '{print $2}' + # Becomes + ps -ef | fzf --multi --header-lines 1 --accept-nth 2 + + git branch | fzf | cut -c3- + # Can be rewritten as + git branch | fzf --accept-nth -1 + ``` +- `--accept-nth` and `--with-nth` now support a template that includes multiple field index expressions in curly braces + ```sh + echo foo,bar,baz | fzf --delimiter , --accept-nth '{1}, {3}, {2}' + # foo, baz, bar + + echo foo,bar,baz | fzf --delimiter , --with-nth '{1},{3},{2},{1..2}' + # foo,baz,bar,foo,bar + ``` +- Added `exclude` and `exclude-multi` actions for dynamically excluding items + ```sh + seq 100 | fzf --bind 'ctrl-x:exclude' + + # 'exclude-multi' will exclude the selected items or the current item + seq 100 | fzf --multi --bind 'ctrl-x:exclude-multi' + ``` +- Preview window now prints wrap indicator when wrapping is enabled + ```sh + seq 100 | xargs | fzf --wrap --preview 'echo {}' --preview-window wrap + ``` +- Bug fixes and improvements + 0.59.0 ------ _Release highlights: https://junegunn.github.io/fzf/releases/0.59.0/_ @@ -365,7 +399,7 @@ _Release highlights: https://junegunn.github.io/fzf/releases/0.54.0/_ - fzf will not start the initial reader when `reload` or `reload-sync` is bound to `start` event. `fzf < /dev/null` or `: | fzf` are no longer required and extraneous `load` event will not fire due to the empty list. ```sh # Now this will work as expected. Previously, this would print an invalid header line. - # `fzf < /dev/null` or `: | fzf` would fix the problem, but then an extraneous + # `fzf < /dev/null` or `: | fzf` would fix the problem, but then an extraneous # `load` event would fire and the header would be prematurely updated. fzf --header 'Loading ...' --header-lines 1 \ --bind 'start:reload:sleep 1; ps -ef' \ diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 14ebb84e..1cea2d13 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -117,12 +117,33 @@ transformed lines (unlike in \fB\-\-preview\fR where fields are extracted from the original lines) because fzf doesn't allow searching against the hidden fields. .TP -.BI "\-\-with\-nth=" "N[,..]" -Transform the presentation of each line using field index expressions +.BI "\-\-with\-nth=" "N[,..] or TEMPLATE" +Transform the presentation of each line using the field index expressions. +For advanced transformation, you can provide a template containing field index +expressions in curly braces. + +.RS +e.g. + # Single expression: drop the first field + echo foo bar baz | fzf --with-nth 2.. + + # Use template to rearrange fields + echo foo,bar,baz | fzf --delimiter , --with-nth '{1},{3},{2},{1..2}' +.RE .TP -.BI "\-\-accept\-nth=" "N[,..]" +.BI "\-\-accept\-nth=" "N[,..] or TEMPLATE" Define which fields to print on accept. The last delimiter is stripped from the -output. +output. For advanced transformation, you can provide a template containing +field index expressions in curly braces. + +.RS +e.g. + # Single expression + echo foo bar baz | fzf --accept-nth 2 + + # Template + echo foo bar baz | fzf --accept-nth '1st: {1}, 2nd: {2}, 3rd: {3}' +.RE .TP .B "+s, \-\-no\-sort" Do not sort the result diff --git a/src/core.go b/src/core.go index 08d9e868..939910b3 100644 --- a/src/core.go +++ b/src/core.go @@ -96,7 +96,7 @@ func Run(opts *Options) (int, error) { var chunkList *ChunkList var itemIndex int32 header := make([]string, 0, opts.HeaderLines) - if len(opts.WithNth) == 0 { + if opts.WithNth == nil { chunkList = NewChunkList(cache, func(item *Item, data []byte) bool { if len(header) < opts.HeaderLines { header = append(header, byteString(data)) @@ -109,6 +109,7 @@ func Run(opts *Options) (int, error) { return true }) } else { + nthTransformer := opts.WithNth(opts.Delimiter) chunkList = NewChunkList(cache, func(item *Item, data []byte) bool { tokens := Tokenize(byteString(data), opts.Delimiter) if opts.Ansi && opts.Theme.Colored && len(tokens) > 1 { @@ -127,15 +128,13 @@ func Run(opts *Options) (int, error) { } } } - trans := Transform(tokens, opts.WithNth) - transformed := JoinTokens(trans) + transformed := nthTransformer(tokens) if len(header) < opts.HeaderLines { header = append(header, transformed) eventBox.Set(EvtHeader, header) return false } item.text, item.colors = ansiProcessor(stringBytes(transformed)) - item.text.TrimTrailingWhitespaces() item.text.Index = itemIndex item.origText = &data itemIndex++ diff --git a/src/options.go b/src/options.go index cad2936e..80079a4c 100644 --- a/src/options.go +++ b/src/options.go @@ -544,8 +544,8 @@ type Options struct { Case Case Normalize bool Nth []Range - WithNth []Range - AcceptNth []Range + WithNth func(Delimiter) func([]Token) string + AcceptNth func(Delimiter) func([]Token) string Delimiter Delimiter Sort int Track trackOption @@ -667,8 +667,6 @@ func defaultOptions() *Options { Case: CaseSmart, Normalize: true, Nth: make([]Range, 0), - WithNth: make([]Range, 0), - AcceptNth: make([]Range, 0), Delimiter: Delimiter{}, Sort: 1000, Track: trackDisabled, @@ -771,6 +769,62 @@ func splitNth(str string) ([]Range, error) { return ranges, nil } +func nthTransformer(str string) (func(Delimiter) func([]Token) string, error) { + // ^[0-9,-.]+$" + if match, _ := regexp.MatchString("^[0-9,-.]+$", str); match { + nth, err := splitNth(str) + if err != nil { + return nil, err + } + return func(Delimiter) func([]Token) string { + return func(tokens []Token) string { + return JoinTokens(Transform(tokens, nth)) + } + }, nil + } + + // {...} {...} ... + placeholder := regexp.MustCompile("{[0-9,-.]+}") + indexes := placeholder.FindAllStringIndex(str, -1) + if indexes == nil { + return nil, errors.New("template should include at least 1 placeholder: " + str) + } + + type NthParts struct { + str string + nth []Range + } + + parts := make([]NthParts, len(indexes)) + idx := 0 + for _, index := range indexes { + if idx < index[0] { + parts = append(parts, NthParts{str: str[idx:index[0]]}) + } + if nth, err := splitNth(str[index[0]+1 : index[1]-1]); err == nil { + parts = append(parts, NthParts{nth: nth}) + } + idx = index[1] + } + if idx < len(str) { + parts = append(parts, NthParts{str: str[idx:]}) + } + + return func(delimiter Delimiter) func([]Token) string { + return func(tokens []Token) string { + str := "" + for _, holder := range parts { + if holder.nth != nil { + str += StripLastDelimiter(JoinTokens(Transform(tokens, holder.nth)), delimiter) + } else { + str += holder.str + } + } + return str + } + }, nil +} + func delimiterRegexp(str string) Delimiter { // Special handling of \t str = strings.ReplaceAll(str, "\\t", "\t") @@ -2387,7 +2441,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error { if err != nil { return err } - if opts.WithNth, err = splitNth(str); err != nil { + if opts.WithNth, err = nthTransformer(str); err != nil { return err } case "--accept-nth": @@ -2395,7 +2449,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error { if err != nil { return err } - if opts.AcceptNth, err = splitNth(str); err != nil { + if opts.AcceptNth, err = nthTransformer(str); err != nil { return err } case "-s", "--sort": diff --git a/src/terminal.go b/src/terminal.go index 273f2650..9a4abf86 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -305,7 +305,7 @@ type Terminal struct { nthAttr tui.Attr nth []Range nthCurrent []Range - acceptNth []Range + acceptNth func([]Token) string tabstop int margin [4]sizeSpec padding [4]sizeSpec @@ -919,7 +919,6 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor nthAttr: opts.Theme.Nth.Attr, nth: opts.Nth, nthCurrent: opts.Nth, - acceptNth: opts.AcceptNth, tabstop: opts.Tabstop, hasStartActions: false, hasResultActions: false, @@ -961,6 +960,9 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor lastAction: actStart, lastFocus: minItem.Index(), numLinesCache: make(map[int32]numLinesCacheValue)} + if opts.AcceptNth != nil { + t.acceptNth = opts.AcceptNth(t.delimiter) + } // This should be called before accessing tui.Color* tui.InitTheme(opts.Theme, renderer.DefaultTheme(), opts.Black, opts.InputBorderShape.Visible(), opts.HeaderBorderShape.Visible()) @@ -1570,9 +1572,11 @@ func (t *Terminal) output() bool { transform := func(item *Item) string { return item.AsString(t.ansi) } - if len(t.acceptNth) > 0 { + if t.acceptNth != nil { transform = func(item *Item) string { - return JoinTokens(StripLastDelimiter(Transform(Tokenize(item.AsString(t.ansi), t.delimiter), t.acceptNth), t.delimiter)) + tokens := Tokenize(item.AsString(t.ansi), t.delimiter) + transformed := t.acceptNth(tokens) + return StripLastDelimiter(transformed, t.delimiter) } } found := len(t.selected) > 0 diff --git a/src/tokenizer.go b/src/tokenizer.go index 057d7405..aaddd17d 100644 --- a/src/tokenizer.go +++ b/src/tokenizer.go @@ -6,6 +6,7 @@ import ( "regexp" "strconv" "strings" + "unicode" "github.com/junegunn/fzf/src/util" ) @@ -211,32 +212,18 @@ func Tokenize(text string, delimiter Delimiter) []Token { return withPrefixLengths(tokens, 0) } -// StripLastDelimiter removes the trailing delimiter and whitespaces from the -// last token. -func StripLastDelimiter(tokens []Token, delimiter Delimiter) []Token { - if len(tokens) == 0 { - return tokens - } - - lastToken := tokens[len(tokens)-1] - - if delimiter.str == nil && delimiter.regex == nil { - lastToken.text.TrimTrailingWhitespaces() - } else { - if delimiter.str != nil { - lastToken.text.TrimSuffix([]rune(*delimiter.str)) - } else if delimiter.regex != nil { - str := lastToken.text.ToString() - locs := delimiter.regex.FindAllStringIndex(str, -1) - if len(locs) > 0 { - lastLoc := locs[len(locs)-1] - lastToken.text.SliceRight(lastLoc[0]) - } +// StripLastDelimiter removes the trailing delimiter and whitespaces +func StripLastDelimiter(str string, delimiter Delimiter) string { + if delimiter.str != nil { + str = strings.TrimSuffix(str, *delimiter.str) + } else if delimiter.regex != nil { + locs := delimiter.regex.FindAllStringIndex(str, -1) + if len(locs) > 0 { + lastLoc := locs[len(locs)-1] + str = str[:lastLoc[0]] } - lastToken.text.TrimTrailingWhitespaces() } - - return tokens + return strings.TrimRightFunc(str, unicode.IsSpace) } // JoinTokens concatenates the tokens into a single string diff --git a/src/util/chars.go b/src/util/chars.go index dd037caa..adde02a6 100644 --- a/src/util/chars.go +++ b/src/util/chars.go @@ -184,11 +184,6 @@ func (chars *Chars) TrailingWhitespaces() int { return whitespaces } -func (chars *Chars) TrimTrailingWhitespaces() { - whitespaces := chars.TrailingWhitespaces() - chars.slice = chars.slice[0 : len(chars.slice)-whitespaces] -} - func (chars *Chars) TrimSuffix(runes []rune) { lastIdx := len(chars.slice) firstIdx := lastIdx - len(runes) diff --git a/test/test_core.rb b/test/test_core.rb index 0d7f68f5..0e60b57e 100644 --- a/test/test_core.rb +++ b/test/test_core.rb @@ -1772,4 +1772,13 @@ class TestCore < TestInteractive assert_equal ['bar,bar,foo :,:bazfoo'], File.readlines(tempname, chomp: true) end end + + def test_accept_nth_template + tmux.send_keys %(echo "foo ,bar,baz" | #{FZF} -d, --accept-nth '1st: {1}, 3rd: {3}, 2nd: {2}' --sync --bind start:accept > #{tempname}), :Enter + wait do + assert_path_exists tempname + # Last delimiter and the whitespaces are removed + assert_equal ['1st: foo, 3rd: baz, 2nd: bar'], File.readlines(tempname, chomp: true) + end + end end diff --git a/test/test_filter.rb b/test/test_filter.rb index dc66ec00..718c6e57 100644 --- a/test/test_filter.rb +++ b/test/test_filter.rb @@ -59,6 +59,13 @@ class TestFilter < TestBase `#{FZF} -f"^he hehe" -x -n 2.. --with-nth 2,1,1 < #{tempname}`.chomp end + def test_with_nth_template + writelines(['hello world ', 'byebye']) + assert_equal \ + 'hello world ', + `#{FZF} -f"^he he.he." -x -n 2.. --with-nth '{2} {1}. {1}.' < #{tempname}`.chomp + end + def test_with_nth_ansi writelines(["\x1b[33mhello \x1b[34;1mworld\x1b[m ", 'byebye']) assert_equal \