diff --git a/CHANGELOG.md b/CHANGELOG.md index e4358226..4dd6e9c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ CHANGELOG ========= +0.16.3 +------ +- Fixed a bug where fzf incorrectly display the lines when straddling tab + characters are trimmed +- Placeholder expression used in `--preview` and `execute` action can + optionally take `+` flag to be used with multiple selections + - e.g. `git log --oneline | fzf --multi --preview 'git show {+1}'` + 0.16.2 ------ - Dropped ncurses dependency diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 9a374d5b..e4321baa 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -262,13 +262,21 @@ Execute the given command for the current line and display the result on the preview window. \fB{}\fR in the command is the placeholder that is replaced to the single-quoted string of the current line. To transform the replacement string, specify field index expressions between the braces (See \fBFIELD INDEX -EXPRESSION\fR for the details). Also, \fB{q}\fR is replaced to the current -query string. +EXPRESSION\fR for the details). .RS e.g. \fBfzf --preview="head -$LINES {}"\fR \fBls -l | fzf --preview="echo user={3} when={-4..-2}; cat {-1}" --header-lines=1\fR +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 +was made) individually quoted. + +e.g. \fBfzf --multi --preview="head -10 {+}"\fR + \fBgit log --oneline | fzf --multi --preview 'git show {+1}'\fR + +Also, \fB{q}\fR is replaced to the current query string. + Note that you can escape a placeholder pattern by prepending a backslash. .RE .TP @@ -461,7 +469,7 @@ e.g. \fBfzf --bind=ctrl-j:accept,ctrl-k:kill-line\fR \fBdown\fR \fIctrl-j ctrl-n down\fR \fBend-of-line\fR \fIctrl-e end\fR \fBexecute(...)\fR (see below for the details) - \fBexecute-multi(...)\fR (see below for the details) + \fRexecute-multi(...)\fR (deprecated in favor of \fB{+}\fR expression) \fBforward-char\fR \fIctrl-f right\fR \fBforward-word\fR \fIalt-f shift-right\fR \fBignore\fR diff --git a/src/terminal.go b/src/terminal.go index 081f7156..43d21d88 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -24,7 +24,7 @@ import ( var placeholder *regexp.Regexp func init() { - placeholder = regexp.MustCompile("\\\\?(?:{[0-9,-.]*}|{q})") + placeholder = regexp.MustCompile("\\\\?(?:{\\+?[0-9,-.]*}|{q})") } type jumpMode int @@ -436,9 +436,9 @@ func (t *Terminal) output() bool { } found := len(t.selected) > 0 if !found { - cnt := t.merger.Length() - if cnt > 0 && cnt > t.cy { - t.printer(t.current()) + current := t.currentItem() + if current != nil { + t.printer(current.AsString(t.ansi)) found = true } } else { @@ -1044,7 +1044,27 @@ func quoteEntry(entry string) string { return "'" + strings.Replace(entry, "'", "'\\''", -1) + "'" } -func replacePlaceholder(template string, stripAnsi bool, delimiter Delimiter, query string, items []*Item) string { +func hasPlusFlag(template string) bool { + for _, match := range placeholder.FindAllString(template, -1) { + if match[0] == '\\' { + continue + } + if match[1] == '+' { + return true + } + } + return false +} + +func replacePlaceholder(template string, stripAnsi bool, delimiter Delimiter, forcePlus bool, query string, allItems []*Item) string { + current := allItems[:1] + selected := allItems[1:] + if current[0] == nil { + current = []*Item{} + } + if selected[0] == nil { + selected = []*Item{} + } return placeholder.ReplaceAllStringFunc(template, func(match string) string { // Escaped pattern if match[0] == '\\' { @@ -1056,6 +1076,16 @@ func replacePlaceholder(template string, stripAnsi bool, delimiter Delimiter, qu return quoteEntry(query) } + plusFlag := forcePlus + if match[1] == '+' { + match = "{" + match[2:] + plusFlag = true + } + items := current + if plusFlag { + items = selected + } + replacements := make([]string, len(items)) if match == "{}" { @@ -1096,8 +1126,12 @@ func replacePlaceholder(template string, stripAnsi bool, delimiter Delimiter, qu }) } -func (t *Terminal) executeCommand(template string, items []*Item) { - command := replacePlaceholder(template, t.ansi, t.delimiter, string(t.input), items) +func (t *Terminal) executeCommand(template string, forcePlus bool) { + valid, list := t.buildPlusList(template, forcePlus) + if !valid { + return + } + command := replacePlaceholder(template, t.ansi, t.delimiter, forcePlus, string(t.input), list) cmd := util.ExecCommand(command) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout @@ -1123,11 +1157,24 @@ func (t *Terminal) hasPreviewWindow() bool { } func (t *Terminal) currentItem() *Item { - return t.merger.Get(t.cy).item + cnt := t.merger.Length() + if cnt > 0 && cnt > t.cy { + return t.merger.Get(t.cy).item + } + return nil } -func (t *Terminal) current() string { - return t.currentItem().AsString(t.ansi) +func (t *Terminal) buildPlusList(template string, forcePlus bool) (bool, []*Item) { + current := t.currentItem() + if !forcePlus && !hasPlusFlag(template) || len(t.selected) == 0 { + return current != nil, []*Item{current, current} + } + sels := make([]*Item, len(t.selected)+1) + sels[0] = current + for i, sel := range t.sortSelected() { + sels[i+1] = sel.item + } + return true, sels } // Loop is called to start Terminal I/O @@ -1184,19 +1231,20 @@ func (t *Terminal) Loop() { if t.hasPreviewer() { go func() { for { - var request *Item + var request []*Item t.previewBox.Wait(func(events *util.Events) { for req, value := range *events { switch req { case reqPreviewEnqueue: - request = value.(*Item) + request = value.([]*Item) } } events.Clear() }) - if request != nil { + // We don't display preview window if no match + if request[0] != nil { command := replacePlaceholder(t.preview.command, - t.ansi, t.delimiter, string(t.input), []*Item{request}) + t.ansi, t.delimiter, false, string(t.input), request) cmd := util.ExecCommand(command) out, _ := cmd.CombinedOutput() t.reqBox.Set(reqPreviewDisplay, string(out)) @@ -1232,17 +1280,12 @@ func (t *Terminal) Loop() { t.printInfo() case reqList: t.printList() - cnt := t.merger.Length() - var currentFocus *Item - if cnt > 0 && cnt > t.cy { - currentFocus = t.currentItem() - } else { - currentFocus = nil - } + currentFocus := t.currentItem() if currentFocus != focused { focused = currentFocus if t.isPreviewEnabled() { - t.previewBox.Set(reqPreviewEnqueue, focused) + _, list := t.buildPlusList(t.preview.command, false) + t.previewBox.Set(reqPreviewEnqueue, list) } } case reqJump: @@ -1348,19 +1391,9 @@ func (t *Terminal) Loop() { switch a.t { case actIgnore: case actExecute: - if t.cy >= 0 && t.cy < t.merger.Length() { - t.executeCommand(a.a, []*Item{t.currentItem()}) - } + t.executeCommand(a.a, false) case actExecuteMulti: - if len(t.selected) > 0 { - sels := make([]*Item, len(t.selected)) - for i, sel := range t.sortSelected() { - sels[i] = sel.item - } - t.executeCommand(a.a, sels) - } else { - return doAction(action{t: actExecute, a: a.a}, mapkey) - } + t.executeCommand(a.a, true) case actInvalid: t.mutex.Unlock() return false @@ -1369,9 +1402,11 @@ func (t *Terminal) Loop() { t.previewer.enabled = !t.previewer.enabled t.tui.Clear() t.resizeWindows() - cnt := t.merger.Length() - if t.previewer.enabled && cnt > 0 && cnt > t.cy { - t.previewBox.Set(reqPreviewEnqueue, t.currentItem()) + if t.previewer.enabled { + valid, list := t.buildPlusList(t.preview.command, false) + if valid { + t.previewBox.Set(reqPreviewEnqueue, list) + } } req(reqList, reqInfo, reqHeader) } diff --git a/test/test_go.rb b/test/test_go.rb index cdd96d10..f730f256 100644 --- a/test/test_go.rb +++ b/test/test_go.rb @@ -879,7 +879,7 @@ class TestGoFZF < TestBase def test_execute_multi output = '/tmp/fzf-test-execute-multi' - opts = %[--multi --bind \\"alt-a:execute-multi(echo {}/{} >> #{output}; sync)\\"] + opts = %[--multi --bind \\"alt-a:execute-multi(echo {}/{+} >> #{output}; sync)\\"] writelines tempname, %w[foo'bar foo"bar foo$bar foobar] tmux.send_keys "cat #{tempname} | #{fzf opts}", :Enter tmux.until { |lines| lines[-2].include? '4/4' } @@ -902,6 +902,43 @@ class TestGoFZF < TestBase File.unlink output rescue nil end + def test_execute_plus_flag + output = tempname + ".tmp" + File.unlink output rescue nil + writelines tempname, ["foo bar", "123 456"] + + tmux.send_keys "cat #{tempname} | #{FZF} --multi --bind 'x:execute(echo {+}/{}/{+2}/{2} >> #{output})'", :Enter + + execute = lambda do + tmux.send_keys 'x', 'y' + tmux.until { |lines| lines[-2].include? '0/2' } + tmux.send_keys :BSpace + tmux.until { |lines| lines[-2].include? '2/2' } + end + + tmux.until { |lines| lines[-2].include? '2/2' } + execute.call + + tmux.send_keys :Up + tmux.send_keys :Tab + execute.call + + tmux.send_keys :Tab + execute.call + + tmux.send_keys :Enter + tmux.prepare + readonce + + assert_equal [ + %[foo bar/foo bar/bar/bar], + %[123 456/foo bar/456/bar], + %[123 456 foo bar/foo bar/456 bar/bar] + ], File.readlines(output).map(&:chomp) + rescue + File.unlink output rescue nil + end + def test_execute_shell # Custom script to use as $SHELL output = tempname + '.out' @@ -1198,7 +1235,7 @@ class TestGoFZF < TestBase end def test_preview - tmux.send_keys %[seq 1000 | sed s/^2$// | #{FZF} --preview 'sleep 0.2; echo {{}-{}}' --bind ?:toggle-preview], :Enter + tmux.send_keys %[seq 1000 | sed s/^2$// | #{FZF} -m --preview 'sleep 0.2; echo {{}-{+}}' --bind ?:toggle-preview], :Enter tmux.until { |lines| lines[1].include?(' {1-1}') } tmux.send_keys :Up tmux.until { |lines| lines[1].include?(' {-}') } @@ -1212,6 +1249,17 @@ class TestGoFZF < TestBase tmux.until { |lines| lines[-2].start_with? ' 28/1000' } tmux.send_keys 'foobar' tmux.until { |lines| !lines[1].include?('{') } + tmux.send_keys 'C-u' + tmux.until { |lines| lines.match_count == 1000 } + tmux.until { |lines| lines[1].include?(' {1-1}') } + tmux.send_keys :BTab + tmux.until { |lines| lines[1].include?(' {-1}') } + tmux.send_keys :BTab + tmux.until { |lines| lines[1].include?(' {3-1 }') } + tmux.send_keys :BTab + tmux.until { |lines| lines[1].include?(' {4-1 3}') } + tmux.send_keys :BTab + tmux.until { |lines| lines[1].include?(' {5-1 3 4}') } end def test_preview_hidden