diff --git a/README.md b/README.md index 3d6bf32..3f30195 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,14 @@ Commands line content (`:Lines`, `:BLines`, `:Marks`, `:Changes`), matched text (`:Rg`, `:Ag`, `:Grep`), tag name (`:Tags`, `:BTags`), or commit hash (`:Commits`, `:BCommits`) +- Most commands support `CTRL-O` to open the current entry in the window fzf + was started from, leaving fzf open, so you can look through several entries + in a single run (`:Files`, `:GFiles`, `:Buffers`, `:History`, `:Locate`, + `:Rg`, `:Ag`, `:Grep`, `:Lines`, `:BLines`, `:Tags`, `:BTags`, `:Marks`, + `:Changes`). Requires fzf 0.74.4 or later, and is not offered in fullscreen + mode, with a layout that leaves no other window (`enew`, `tabnew`), on + Windows, or when the key is already used by `g:fzf_vim.paste_key` or + `g:fzf_action` - Bang-versions of the commands (e.g. `Ag!`) will open fzf in fullscreen - You can set `g:fzf_vim.command_prefix` to give the same prefix to the commands - e.g. `let g:fzf_vim.command_prefix = 'Fzf'` and you have `FzfFiles`, etc. @@ -173,6 +181,25 @@ command inserts). Customize it with `g:fzf_vim.paste_key`. let g:fzf_vim.paste_key = 'alt-enter' ``` +#### Show key + +Most commands support a key that opens the current entry in the window fzf +was started from and leaves fzf open, so you can look through several entries +in one run (see the command list above). It is labelled `Show` in the footer +to set it apart from `Enter`, which opens the entry and closes fzf. Customize +it with `g:fzf_vim.show_key`, or set it to an empty string to turn the key +off. A comma separated list binds every key in it. It takes the key over from +`$FZF_DEFAULT_OPTS`, so pick another key or turn it off to keep a binding of +your own. + +```vim +" Key to open the current entry, leaving fzf open (default: 'ctrl-o') +let g:fzf_vim.show_key = 'ctrl-o' + +" Use CTRL-O and double-click +let g:fzf_vim.show_key = 'ctrl-o,double-click' +``` + #### Command-level options ```vim diff --git a/autoload/fzf/vim.vim b/autoload/fzf/vim.vim index 33e9789..8897f75 100755 --- a/autoload/fzf/vim.vim +++ b/autoload/fzf/vim.vim @@ -352,15 +352,21 @@ for s:color_name in keys(s:ansi) \ "endfunction" endfor -function! s:build_hint(entries, defaults) +" Keys in a:000[0] are already bound by this run, so they are left out of the +" defaults. Enter is fzf's accept rather than a g:fzf_action entry, so taking +" it over would otherwise list it twice, as Show and as Open +function! s:build_hint(entries, defaults, ...) let entries = copy(a:entries) + let taken = get(a:, 1, []) let keys = [] let actions = get(g:, 'fzf_action', s:default_action) if a:defaults - call add(entries, ['Enter', 'Open']) - for [key, name, label] in [['ctrl-x', 'C-X', 'HSplit'], ['ctrl-v', 'C-V', 'VSplit'], ['ctrl-t', 'C-T', 'New tab']] + if index(taken, 'enter') < 0 && index(taken, 'return') < 0 + call add(entries, ['Enter', 'Open']) + endif + for [key, name, label] in [['ctrl-x', 'C-X', 'HSplit'], ['ctrl-v', 'C-V', 'VSplit'], ['ctrl-t', 'C-T', 'Tab']] let Cmd = get(actions, key, '') - if type(Cmd) == s:TYPE.string && Cmd ==# s:default_action[key] + if index(taken, key) < 0 && type(Cmd) == s:TYPE.string && Cmd ==# s:default_action[key] call add(entries, [name, label]) endif endfor @@ -400,7 +406,18 @@ function! s:fzf(name, opts, extra) " Command-level fzf options call s:merge_opts(merged, s:conf(a:name.'_options', [])) - return fzf#run(s:wrap(a:name, merged, bang)) + let fifo = s:add_hints(merged, bang) + let ok = 0 + try + let result = fzf#run(s:wrap(a:name, merged, bang)) + let ok = 1 + return result + finally + " Nothing calls 'exit' if the run never started + if !ok && !empty(fifo) + call fzf#vim#ipc#stop(fifo) + endif + endtry endfunction let s:default_action = { @@ -416,6 +433,45 @@ function! s:paste_key() return s:conf('paste_key', 'alt-enter') endfunction +" Key that opens the current entry in the window fzf was started from and +" leaves fzf open, labelled 'Show' in the footer to set it apart from Enter, +" which opens it and closes fzf. Commands opt in by setting `_show` on the +" spec (see s:add_hints). Configurable via g:fzf_vim.show_key, and set to an +" empty string to turn the key off. +function! s:show_key() + return s:conf('show_key', 'ctrl-o') +endfunction + +" A key can be a comma separated list, which fzf binds to every key in it. +" fzf escapes 'alt-,' before splitting on the comma, so do the same. It also +" lowercases a key name before matching it, except for 'alt-X' and a bare +" character, where it takes the rune as typed, so fold the same ones here and +" the comparisons below agree with fzf +function! s:split_keys(keys) + let sep = nr2char(1) + let keys = map(split(substitute(a:keys, '\c\(alt-\),', '\1'.sep, 'g'), ','), + \ 'substitute(v:val, sep, ",", "g")') + " 'alt-X' keeps X as typed but not its prefix, so fold only the prefix there + return map(keys, 'strchars(v:val) == 1 ? v:val + \ : v:val =~? "^alt-.$" ? "alt-".v:val[4:] : tolower(v:val)') +endfunction + +" 'ctrl-alt-x' -> 'C-A-X' and 'double-click' -> 'Dbl-Click', to match the +" other footer labels. Only the first key of a list is labelled, so that an +" alternative like 'double-click' does not crowd the footer with a name +" several times the width of the others +function! s:key_label(keys) + let label = substitute(get(s:split_keys(a:keys), 0, ''), '\c\ 0 && fnamemodify(bufname(bufnr), ':p') ==# fnamemodify(a:path, ':p') + return [] + endif + return ['keepalt keepjumps hide edit '.s:escape(a:path)] +endfunction + +" :edit raises the swap dialog before it throws, and a dialog from this +" callback is invisible behind fzf and swallows the next keys. Refuse the +" file instead and leave the real prompt to the sink. +function! s:swap_abort() + let s:swap_clash = expand('') + let v:swapchoice = 'q' +endfunction + +" The buffer number starts the third tab separated field. Matching the whole +" line would find brackets in the path first, as in 'log[12].txt' +function! s:buffer_number(line) + return matchstr(get(split(a:line, "\t", 1), 2, ''), '\[\zs[0-9]*\ze\]') +endfunction + +" fzf#run restores the working directory while fzf runs and only re-applies +" 'dir' for the sink, so a relative entry has to be resolved here +function! s:in_dir(dir, path) abort + " filereadable does not expand a leading tilde, which :History reports + if a:path =~# '^\~' + return fnamemodify(a:path, ':p') + endif + if empty(a:dir) || a:path =~# '^/' || a:path =~# '^\a:[\\/]' + return a:path + endif + return fnamemodify(expand(escape(a:dir, '[]*?{}')), ':p').a:path +endfunction + +" Shows the entry without closing fzf. The callback can fire while the fzf +" window is current, so the entry goes to the window fzf was started from. +" Scopes the options s:goto_entry needs, which every early return has to +" restore, hence the split. +function! s:show_entry(winid, dir, kind, entry) abort + if empty(a:entry) + return + endif + " 'acd' would move the cwd on the first open, and s:ag_to_qf resolves a + " relative match against it. Tag addresses are searched with the settings + " s:tags_sink uses. + let [magic, wrapscan, acd] = [&magic, &wrapscan, &acd] + let &acd = 0 + if a:kind ==# 'tags' || a:kind ==# 'btags' + let [&magic, &wrapscan] = [0, 1] + endif + try + call s:goto_entry(a:winid, a:dir, a:kind, a:entry) + finally + let [&magic, &wrapscan, &acd] = [magic, wrapscan, acd] + endtry +endfunction + +" Parses the entry the way that command's sink does and moves the window to +" it. Runs with the options s:show_entry set. +function! s:goto_entry(winid, dir, kind, entry) abort + let entry = a:entry + if a:kind ==# 'gitstatus' + " ' M path', '?? path', or a rename reported as 'old -> new' + let entry = substitute(entry[3:], '.* -> ', '', '') + endif + if a:kind ==# 'buffer' + let bufnr = s:buffer_number(entry) + if empty(bufnr) + return + endif + let cmds = ['keepalt keepjumps hide buffer '.bufnr] + elseif a:kind ==# 'lines' + " bufnr, name, line number, text + let chunks = split(entry, "\t", 1) + if len(chunks) < 3 || !bufexists(str2nr(chunks[0])) + return + endif + let cmds = ['keepalt keepjumps hide buffer '.str2nr(chunks[0]), + \ 'keepjumps '.str2nr(chunks[2]), 'normal! ^zvzz'] + elseif a:kind ==# 'blines' + " line number, text, in the buffer the run started from + let lnum = str2nr(split(entry, "\t", 1)[0]) + if lnum <= 0 + return + endif + let cmds = ['keepjumps '.lnum, 'normal! ^zvzz'] + elseif a:kind ==# 'tags' + let parts = split(entry, '\t\zs') + if len(parts) < 3 + return + endif + let excmd = matchstr(join(parts[2:-2], '')[:-2], '^.\{-}\ze;\?"\t') + let relpath = parts[1][:-2] + let path = relpath =~ (s:is_win ? '^[A-Z]:\' : '^/') + \ ? relpath : join([fnamemodify(parts[-1], ':h'), relpath], '/') + let path = expand(path, 1) + if empty(excmd) || !filereadable(path) + return + endif + let cmds = ['keepalt keepjumps hide edit '.s:escape(path), + \ 'keepjumps '.excmd, 'normal! ^zvzz'] + elseif a:kind ==# 'btags' + let parts = split(entry, "\t") + if len(parts) < 3 + return + endif + let cmds = ['keepjumps '.parts[2], 'normal! zvzz'] + elseif a:kind ==# 'marks' + let mark = matchstr(entry, '^\s*\zs\S') + if empty(mark) + return + endif + let cmds = ['keepalt keepjumps hide normal! `'.mark.'zvzz'] + elseif a:kind ==# 'changes' + " buffer, offset, line, column, text + let parts = split(entry) + if len(parts) < 4 || !bufexists(str2nr(parts[0])) + return + endif + " The offset forms are relative to the current position, so use the + " recorded position instead and stay idempotent across presses + let cmds = ['keepalt keepjumps hide buffer '.str2nr(parts[0]), + \ printf('keepjumps call cursor(%d, %d)', str2nr(parts[2]), str2nr(parts[3])), + \ 'normal! zvzz'] + elseif a:kind ==# 'grep' + " Vim sends one line per callback, so multi-line entries arrive in pieces + try + let entry = s:ag_to_qf(entry) + catch + return + endtry + " A continuation line can still parse, e.g. 'timeout: 30' yields a line + " number and a filename that does not exist + if empty(get(entry, 'lnum', '')) || !filereadable(s:in_dir(a:dir, get(entry, 'filename', ''))) + return + endif + let cmds = s:edit_cmds(a:winid, s:in_dir(a:dir, entry.filename)) + \ + ['keepjumps '.entry.lnum] + if has_key(entry, 'col') + call add(cmds, printf('call cursor(0, %d)', entry.col)) + endif + call add(cmds, 'normal! zvzz') + else + let path = s:in_dir(a:dir, entry) + if !filereadable(path) + return + endif + let cmds = s:edit_cmds(a:winid, path) + endif + + " fzf#run opens its own tabpage for a layout it cannot split, and s:can_show + " cannot tell in advance without copying that decision out of the plugin. + " Say so rather than doing nothing. win_id2tabwin() reports 0 for a closed + " window as well as for one on another tabpage + let [tab, win] = win_id2tabwin(a:winid) + if !win + call s:warn('The window fzf started from is gone') + return + endif + if tab != tabpagenr() + call s:warn('Cannot show the entry from another tab page') + return + endif + + let s:swap_clash = '' + augroup fzf_vim_peek + autocmd! + autocmd SwapExists * call s:swap_abort() + augroup END + try + call win_execute(a:winid, cmds) + redraw + catch + " s:callback ignores E325 for the same reason, and redraw would wipe this + if stridx(v:exception, ':E325:') < 0 + call s:warn(v:exception) + endif + finally + autocmd! fzf_vim_peek + endtry + if !empty(s:swap_clash) + call s:warn('Swap file exists: '.fnamemodify(s:swap_clash, ':t')) + endif +endfunction + +" Whether the show key can be offered for this run. Decided from the merged +" spec, since the layout can arrive with the per-call spec, and a bang puts +" fzf on its own tabpage where the window we would open in is not visible. +function! s:can_show(spec, bang, key) abort + " Older fzf blocks Vim in popup and fullscreen modes, so the callback would + " only run after fzf exits, overriding whatever was selected. fzf#run is + " only asynchronous when it has a terminal to put fzf in, and win32unix + " without winpty falls back to a blocking run. Windows has no printf. + if get(g:, 'loaded_fzf', 0) < 20260821 || !exists('*win_execute') || a:bang + \ || !(has('nvim-0.2.1') || (has('terminal') && has('patch-8.0.995'))) + \ || has('win32unix') || s:is_win + return 0 + endif + " Checked here so that ipc#start cannot warn on every invocation + if !executable('mkfifo') || (!exists('*job_start') && !exists('*jobstart')) + return 0 + endif + " fzf matches --expect before the key bindings, so an action or the paste + " key would win over ours. Any key of the list losing disables all of them, + " so that the footer cannot advertise a key that does nothing + let actions = get(g:, 'fzf_action', s:default_action) + let paste = s:split_keys(s:paste_key()) + for key in s:split_keys(a:key) + if has_key(actions, key) || index(paste, key) >= 0 + return 0 + endif + endfor + + " 'enew' leaves no other window, and a tab layout puts fzf on a tabpage of + " its own, where the window we would open in is not visible + " s:layout_keys here is the subset this script strips, so spell out the full + " set; a spec with 'tmux' must not fall back to g:fzf_layout + let keys = ['window', 'popup', 'tmux', 'up', 'down', 'left', 'right'] + let layout = empty(filter(keys, 'has_key(a:spec, v:val)')) + \ ? get(g:, 'fzf_layout', {}) : a:spec + if (has_key(layout, 'tmux') || has_key(layout, 'popup')) + \ && !exists('$TMUX') && !exists('$ZELLIJ') + return 0 + endif + + let window = get(layout, 'window', '') + return type(window) != s:TYPE.string + \ || window !~# '\\|tabnew\|tabedit\|\' +endfunction + +" Installs the show key binding and the key hint footer. '_show' names the entry +" kind, '_hint' opts in to the footer and carries the command's own keys. +" Returns the fifo of the ipc channel it started, so the caller can stop it if +" the run never reaches 'exit' +function! s:add_hints(spec, bang) abort + let started = '' + let taken = [] + let kind = s:pluck(a:spec, '_show', '') + let entries = s:pluck(a:spec, '_hint', 0) + let key = s:show_key() + if !empty(kind) && !empty(key) && s:can_show(a:spec, a:bang, key) + let dir = get(a:spec, 'dir', '') + let fifo = fzf#vim#ipc#start(function('s:show_entry', + \ [win_getid(), empty(dir) ? getcwd() : dir, kind])) + if !empty(fifo) + " Wrap an 'exit' the spec already carries. call() drops the dict of a + " dict function, so the wrapped ones are partials + let Exit = get(a:spec, 'exit', 0) + let a:spec.exit = { code -> [fzf#vim#ipc#stop(fifo), + \ type(Exit) == s:TYPE.funcref ? call(Exit, [code]) : 0] } + call s:prepend_opts(a:spec, ['--bind', key.':execute-silent:printf ''%s\n'' {} > '.fzf#shellescape(fifo)]) + let entries = [[s:key_label(key), 'Show']] + (type(entries) == s:TYPE.list ? entries : []) + let started = fifo + let taken = s:split_keys(key) + endif + endif + if type(entries) == s:TYPE.list + call s:prepend_opts(a:spec, ['--footer', s:build_hint(entries, 1, taken)]) + endif + return started +endfunction + function! fzf#vim#files(dir, ...) let args = {} if !empty(a:dir) @@ -565,6 +890,8 @@ function! fzf#vim#files(dir, ...) let args.options = ['--scheme', 'path', '-m', '--prompt', strwidth(dir) < &columns / 2 - 20 ? dir : '> '] let args._paste = 1 + let args._show = 'file' + let args._hint = [] call s:merge_opts(args, s:conf('files_options', [])) return s:fzf('files', args, a:000) endfunction @@ -643,6 +970,8 @@ function! fzf#vim#lines(...) return s:fzf('lines', { \ 'source': lines, \ 'sink*': s:function('s:line_handler'), + \ '_show': 'lines', + \ '_hint': [], \ '_paste': 1, \ 'options': s:reverse_list(['--tiebreak=index', '--prompt', 'Lines> ', '--ansi', '--extended', '--nth='.nth.'..', '--tabstop=1', '--query', query, '--multi']) \}, args) @@ -688,6 +1017,8 @@ function! fzf#vim#buffer_lines(...) return s:fzf('blines', { \ 'source': s:buffer_lines(query), \ 'sink*': s:function('s:buffer_line_handler'), + \ '_show': 'blines', + \ '_hint': [], \ '_paste': 1, \ 'options': s:reverse_list(['+m', '--tiebreak=index', '--multi', '--prompt', 'BLines> ', '--ansi', '--extended', '--nth=2..', '--tabstop=1']) \}, args) @@ -696,23 +1027,20 @@ endfunction " ------------------------------------------------------------------ " Colors " ------------------------------------------------------------------ -function! s:colors_exit(code) - if exists('s:colors_name') - if a:code > 0 && s:colors_name != g:colors_name - execute 'colo' s:colors_name - endif - unlet s:colors_name +function! s:colors_exit(fifo, original, code) + if !empty(a:original) && a:code > 0 && a:original !=# get(g:, 'colors_name', '') + execute 'colo' a:original endif - call fzf#vim#ipc#stop() + call fzf#vim#ipc#stop(a:fifo) endfunction function! fzf#vim#colors(...) let colors = getcompletion('', 'color') " Put the current colorscheme at the top - if exists('g:colors_name') - let s:colors_name = g:colors_name - let colors = [g:colors_name] + filter(colors, 'g:colors_name != v:val') + let original = get(g:, 'colors_name', '') + if !empty(original) + let colors = [original] + filter(colors, 'original != v:val') endif let spec = { @@ -721,17 +1049,27 @@ function! fzf#vim#colors(...) \ 'options': ['+m', '--prompt', 'Colors> '] \} + let fifo = '' if !a:1 " We can't set up IPC in fullscreen mode in Vim let fifo = fzf#vim#ipc#start({ msg -> execute('colo '.msg) }) if len(fifo) - call extend(spec.options, ['--no-tmux', '--no-padding', '--no-margin', '--bind', 'focus:execute-silent:echo {} > '.fifo]) - let spec.exit = s:function('s:colors_exit') + call extend(spec.options, ['--no-tmux', '--no-padding', '--no-margin', '--bind', 'focus:execute-silent:printf ''%s\n'' {} > '.fzf#shellescape(fifo)]) + let spec.exit = function(s:function('s:colors_exit'), [fifo, original]) let maxwidth = max(map(copy(colors), 'strwidth(v:val)')) let spec.window = { 'width': maxwidth + 8, 'height': len(colors) + 5 } endif endif - call s:fzf('colors', spec, a:000) + let ok = 0 + try + call s:fzf('colors', spec, a:000) + let ok = 1 + finally + " Nothing calls 'exit' if the run never started + if !ok && !empty(fifo) + call fzf#vim#ipc#stop(fifo) + endif + endtry endfunction " ------------------------------------------------------------------ @@ -741,6 +1079,8 @@ function! fzf#vim#locate(query, ...) return s:fzf('locate', { \ 'source': 'locate '.a:query, \ '_paste': 1, + \ '_show': 'file', + \ '_hint': [], \ 'options': '-m --prompt "Locate> "' \}, a:000) endfunction @@ -818,6 +1158,8 @@ function! fzf#vim#history(...) return s:fzf('history-files', { \ 'source': fzf#vim#_recent_files(), \ '_paste': 1, + \ '_show': 'file', + \ '_hint': [], \ 'options': ['-m', '--header-lines', !empty(expand('%')), '--prompt', 'Hist> '] \}, a:000) endfunction @@ -868,6 +1210,8 @@ function! fzf#vim#gitfiles(args, ...) \ 'source': source, \ 'dir': root, \ '_paste': 1, + \ '_show': 'file', + \ '_hint': [], \ 'options': '--scheme path -m --read0 --prompt "GitFiles> "' \}, a:000) endif @@ -899,6 +1243,8 @@ function! fzf#vim#gitfiles(args, ...) return self.common_sink(lines) endfunction let wrapped['sink*'] = remove(wrapped, 'newsink') + let wrapped._show = 'gitstatus' + let wrapped._hint = [] return s:fzf('gfiles-diff', wrapped, a:000) endfunction @@ -931,9 +1277,9 @@ function! s:bufopen(lines) endif if s:is_paste(a:lines) return fzf#vim#paste(map(a:lines[1:], - \ "fnamemodify(bufname(str2nr(matchstr(v:val, '\\[\\zs[0-9]*\\ze\\]'))), ':~:.')")) + \ "fnamemodify(bufname(str2nr(s:buffer_number(v:val))), ':~:.')")) endif - let b = matchstr(a:lines[1], '\[\zs[0-9]*\ze\]') + let b = s:buffer_number(a:lines[1]) if empty(a:lines[0]) && s:conf('buffers_jump', 0) let [t, w] = s:find_open_window(b) if t @@ -945,16 +1291,11 @@ function! s:bufopen(lines) execute 'buffer' b endfunction -function! s:buffers_exit(code) - if !exists('s:buffers_delete_file') - return - endif - let path = s:buffers_delete_file - unlet s:buffers_delete_file - let lines = filereadable(path) ? readfile(path) : [] - call delete(path) +function! s:buffers_exit(path, code) + let lines = filereadable(a:path) ? readfile(a:path) : [] + call delete(a:path) for line in lines - let b = matchstr(line, '\[\zs[0-9]*\ze\]') + let b = s:buffer_number(line) if !empty(b) silent! execute 'bdelete' b endif @@ -996,19 +1337,22 @@ function! fzf#vim#buffers(...) endif let sorted = sort(buffers, 's:sort_buffers') let tabstop = len(max(sorted)) >= 4 ? 9 : 8 - let s:buffers_delete_file = tempname() - let hint = s:build_hint([['C-A-X', 'Unload']], 1) - let options = ['+m', '-x', '--tiebreak=index', '--ansi', '-d', '\t', '--with-nth', '3..', '-n', '2,1..2', '--prompt', 'Buf> ', '--query', query, '--preview-window', '+{2}/2', '--tabstop', tabstop, '--bind', 'ctrl-alt-x:execute-silent(echo {} >> '.s:buffers_delete_file.')+exclude', '--footer', hint] - if bufnr('') == get(sorted, 0, 0) - call extend(options, ['--sync', '--bind', 'start:pos:2']) - endif + let delete_file = tempname() + " map() below rewrites sorted in place, so read the first buffer first + let first = get(sorted, 0, 0) let spec = { \ 'source': map(sorted, 'fzf#vim#_format_buffer(v:val)'), \ 'sink*': s:function('s:bufopen'), - \ 'exit': s:function('s:buffers_exit'), - \ '_paste': 1, - \ 'options': options + \ 'exit': function(s:function('s:buffers_exit'), [delete_file]), + \ '_paste': 1 \} + let spec._show = 'buffer' + let spec._hint = [['C-A-X', 'Unload']] + let options = ['+m', '-x', '--tiebreak=index', '--ansi', '-d', '\t', '--with-nth', '3..', '-n', '2,1..2', '--prompt', 'Buf> ', '--query', query, '--preview-window', '+{2}/2', '--tabstop', tabstop, '--bind', 'ctrl-alt-x:execute-silent(echo {} >> '.fzf#shellescape(delete_file).')+exclude'] + if bufnr('') == first + call extend(options, ['--sync', '--bind', 'start:pos:2']) + endif + call s:merge_opts(spec, options) return s:fzf('buffers', spec, args) endfunction @@ -1128,6 +1472,8 @@ function! fzf#vim#grep(grep_command, ...) endif let opts._paste = 1 + let opts._show = 'grep' + let opts._hint = [['A-a/d', 'Select/deselect all']] function! opts.sink(lines) closure return s:ag_handler(get(opts, 'name', name), a:lines) endfunction @@ -1174,6 +1520,8 @@ function! fzf#vim#grep2(command_prefix, query, ...) call remove(args, 0) endif let opts._paste = 1 + let opts._show = 'grep' + let opts._hint = [['A-a/d', 'Select/deselect all']] function! opts.sink(lines) closure return s:ag_handler(name, a:lines) endfunction @@ -1243,6 +1591,8 @@ function! fzf#vim#buffer_tags(query, ...) return s:fzf('btags', { \ 'source': s:btags_source(tag_cmds), \ 'sink*': s:function('s:btags_sink'), + \ '_show': 'btags', + \ '_hint': [], \ '_paste': 1, \ 'options': s:reverse_list(['-m', '-d', '\t', '--with-nth', '1,4..', '-n', '1', '--prompt', 'BTags> ', '--query', a:query, '--preview-window', '+{3}/2'])}, args) catch @@ -1352,6 +1702,8 @@ function! fzf#vim#tags(query, ...) return s:fzf('tags', { \ 'source': join(['perl', fzf#shellescape(s:bin.tags), join(args)]), \ 'sink*': s:function('s:tags_sink'), + \ '_show': 'tags', + \ '_hint': [], \ '_paste': 1, \ 'options': extend(opts, ['--nth', '1..2', '-m', '-d', '\t', '--tiebreak=begin', '--prompt', 'Tags> ', '--query', a:query])}, a:000) endfunction @@ -1509,6 +1861,8 @@ function! fzf#vim#changes(...) return s:fzf('changes', { \ 'source': all_changes, \ 'sink*': s:function('s:changes_sink'), + \ '_show': 'changes', + \ '_hint': [], \ '_paste': 1, \ 'options': printf('+m -x --ansi --tiebreak=index --header-lines=1 --cycle --scroll-off 999 --sync --bind start:pos:%d --prompt "Changes> " --list-border --header-border inline --info inline-right --no-separator', cursor)}, a:000) endfunction @@ -1550,6 +1904,8 @@ function! fzf#vim#marks(...) abort return s:fzf('marks', { \ 'source': extend(list[0:0], map(list[1:], 's:format_mark(v:val)')), \ 'sink*': s:function('s:mark_sink'), + \ '_show': 'marks', + \ '_hint': [], \ '_paste': 1, \ 'options': '+m -x --ansi --tiebreak=index --header-lines 1 --tiebreak=begin --prompt "Marks> " --list-border --header-border inline --info inline-right --no-separator'}, extra) endfunction @@ -1564,13 +1920,13 @@ function! s:jump_format(line) return line endfunction -function! s:jump_sink(lines) +function! s:jump_sink(pos, lines) if len(a:lines) < 2 return endif keepjumps call s:action_for(a:lines[0]) let idx = str2nr(a:lines[1]) - let delta = idx - s:jump_current - 1 + let delta = idx - a:pos - 1 if delta < 0 execute 'normal! ' . -delta . "\" else @@ -1584,7 +1940,7 @@ function! fzf#vim#jumps(...) if empty(jumps) return s:warn('No jumps') endif - let s:jumplist = [] + let jumplist = [] for idx in range(len(jumps)) let jump = jumps[idx] let loc = expand('#'.jump.bufnr.':p:~:.') @@ -1596,13 +1952,12 @@ function! fzf#vim#jumps(...) let loc .= ':'.jump.col endif let line = printf('%-2d %s: %s', idx+1, loc, getbufoneline(jump.bufnr, jump.lnum)) - call add(s:jumplist, line) + call add(jumplist, line) endfor - let s:jump_current = pos let current = -pos-1 return s:fzf('jumps', { - \ 'source': map(s:jumplist, 's:jump_format(v:val)'), - \ 'sink*': s:function('s:jump_sink'), + \ 'source': map(jumplist, 's:jump_format(v:val)'), + \ 'sink*': function(s:function('s:jump_sink'), [pos]), \ 'options': ['+m', '-x', '--ansi', '--tiebreak=index', '--cycle', '--scroll-off=999', '--sync', '--bind', 'start:pos('.current.')+offset-middle', '--tac', '--tiebreak=begin', '--prompt', 'Jumps> ', '--preview-window', '+{3}/2', '--tabstop=2', '--delimiter', '[:\s]+'], \ }, a:000) endfunction @@ -1626,16 +1981,25 @@ function! fzf#vim#helptags(...) let sorted = sort(split(globpath(&runtimepath, 'doc/tags', 1), '\n')) let tags = exists('*uniq') ? uniq(sorted) : fzf#vim#_uniq(sorted) - if exists('s:helptags_script') - silent! call delete(s:helptags_script) - endif - let s:helptags_script = tempname() + let script = tempname() - call writefile(['for my $filename (@ARGV) { open(my $file,q(<),$filename) or die; while (<$file>) { /(.*?)\t(.*?)\t(.*)/; push @lines, sprintf(qq('.s:green('%-40s', 'Label').'\t%s\t%s\t%s\n), $1, $2, $filename, $3); } close($file) or die; } print for sort @lines;'], s:helptags_script) - return s:fzf('helptags', { - \ 'source': 'perl '.fzf#shellescape(s:helptags_script).' '.join(map(tags, 'fzf#shellescape(v:val)')), + call writefile(['for my $filename (@ARGV) { open(my $file,q(<),$filename) or die; while (<$file>) { /(.*?)\t(.*?)\t(.*)/; push @lines, sprintf(qq('.s:green('%-40s', 'Label').'\t%s\t%s\t%s\n), $1, $2, $filename, $3); } close($file) or die; } print for sort @lines;'], script) + let spec = { + \ 'source': 'perl '.fzf#shellescape(script).' '.join(map(tags, 'fzf#shellescape(v:val)')), \ 'sink': s:function('s:helptag_sink'), - \ 'options': ['--ansi', '+m', '--tiebreak=begin', '--with-nth', '..3']}, a:000) + \ 'exit': { _ -> delete(script) }, + \ 'options': ['--ansi', '+m', '--tiebreak=begin', '--with-nth', '..3']} + let ok = 0 + try + let result = s:fzf('helptags', spec, a:000) + let ok = 1 + return result + finally + " Nothing calls 'exit' if the run never started + if !ok + call delete(script) + endif + endtry endfunction " ------------------------------------------------------------------ @@ -1860,19 +2224,19 @@ function! s:highlight_keys(str) \ '', s:blue('', 'SpecialKey'), 'g') endfunction -function! s:key_sink(line) +function! s:key_sink(gv, cnt, reg, op, line) let key = matchstr(a:line, '^\S*') redraw - call feedkeys(s:map_gv.s:map_cnt.s:map_reg, 'n') - call feedkeys(s:map_op. + call feedkeys(a:gv.a:cnt.a:reg, 'n') + call feedkeys(a:op. \ substitute(key, '<[^ >]\+>', '\=eval("\"\\".submatch(0)."\"")', 'g')) endfunction function! fzf#vim#maps(mode, ...) - let s:map_gv = a:mode == 'x' ? 'gv' : '' - let s:map_cnt = v:count == 0 ? '' : v:count - let s:map_reg = empty(v:register) ? '' : ('"'.v:register) - let s:map_op = a:mode == 'o' ? v:operator : '' + let gv = a:mode == 'x' ? 'gv' : '' + let cnt = v:count == 0 ? '' : v:count + let reg = empty(v:register) ? '' : ('"'.v:register) + let op = a:mode == 'o' ? v:operator : '' redir => cout silent execute 'verbose' a:mode.'map' @@ -1900,7 +2264,7 @@ function! fzf#vim#maps(mode, ...) let pcolor = a:mode == 'x' ? 9 : a:mode == 'o' ? 10 : 12 return s:fzf('maps', { \ 'source': colored, - \ 'sink': s:function('s:key_sink'), + \ 'sink': function(s:function('s:key_sink'), [gv, cnt, reg, op]), \ 'options': '--prompt "Maps ('.a:mode.')> " --ansi --no-hscroll --nth 1,.. --color prompt:'.pcolor}, a:000) endfunction @@ -1916,8 +2280,8 @@ endfunction function! s:complete_trigger() let opts = copy(s:opts) call s:prepend_opts(opts, ['+m', '-q', s:query]) - let opts['sink*'] = s:function('s:complete_insert') - let s:reducer = s:pluck(opts, 'reducer', s:function('s:first_line')) + let Reducer = s:pluck(opts, 'reducer', s:function('s:first_line')) + let opts['sink*'] = function(s:function('s:complete_insert'), [s:query, s:eol, Reducer]) call fzf#run(opts) endfunction @@ -1926,21 +2290,21 @@ function! s:first_line(lines) return a:lines[0] endfunction -function! s:complete_insert(lines) +function! s:complete_insert(query, eol, Reducer, lines) if empty(a:lines) return endif - let chars = strchars(s:query) + let chars = strchars(a:query) if chars == 0 | let del = '' elseif chars == 1 | let del = '"_x' else | let del = (chars - 1).'"_dvh' endif - let data = call(s:reducer, [a:lines]) + let data = call(a:Reducer, [a:lines]) let ve = &ve set ve= - execute 'normal!' ((s:eol || empty(chars)) ? '' : 'h').del.(s:eol ? 'a': 'i').data + execute 'normal!' ((a:eol || empty(chars)) ? '' : 'h').del.(a:eol ? 'a': 'i').data let &ve = ve if mode() =~ 't' call feedkeys('a', 'n') diff --git a/autoload/fzf/vim/ipc.vim b/autoload/fzf/vim/ipc.vim index 6aa54f5..f0c47e2 100644 --- a/autoload/fzf/vim/ipc.vim +++ b/autoload/fzf/vim/ipc.vim @@ -28,6 +28,9 @@ function! s:warn(message) return 0 endfunction +" Channels in flight, keyed by fifo path, so concurrent fzf runs do not clash +let s:channels = {} + function! fzf#vim#ipc#start(Callback) if !exists('*job_start') && !exists('*jobstart') call s:warn('job_start/jobstart function not supported') @@ -39,57 +42,95 @@ function! fzf#vim#ipc#start(Callback) return '' endif - call fzf#vim#ipc#stop() - - let g:fzf_ipc = { 'fifo': tempname(), 'callback': a:Callback } - if !filereadable(g:fzf_ipc.fifo) - call system('mkfifo '..shellescape(g:fzf_ipc.fifo)) - if v:shell_error + let fifo = tempname() + call system('mkfifo '..shellescape(fifo)) + if v:shell_error + if !exists('s:mkfifo_failed') + let s:mkfifo_failed = 1 call s:warn('Failed to create fifo') endif + return '' endif - call fzf#vim#ipc#restart() + let s:channels[fifo] = { 'fifo': fifo, 'callback': a:Callback } + call fzf#vim#ipc#restart(fifo) + if !s:running(s:channels[fifo].job) + call fzf#vim#ipc#stop(fifo) + return '' + endif - return g:fzf_ipc.fifo + return fifo endfunction -function! fzf#vim#ipc#restart() - if !exists('g:fzf_ipc') +" Nvim hands over everything one read produced, which can be several messages +" as well as the trailing empty element. Vim's out_cb passes one line at a +" time, so deliver line by line and both behave the same. +function! s:deliver(Callback, lines) + for line in a:lines + if !empty(line) + call call(a:Callback, [line]) + endif + endfor +endfunction + +" The reader is gone, so leaving the channel registered would block the next +" write on a fifo nothing reads +function! s:drop(fifo) + if has_key(s:channels, a:fifo) + call delete(remove(s:channels, a:fifo).fifo) + endif +endfunction + +function! fzf#vim#ipc#restart(fifo) + if !has_key(s:channels, a:fifo) throw 'fzf#vim#ipc not started' endif - let Callback = g:fzf_ipc.callback + let chan = s:channels[a:fifo] + let Callback = chan.callback + let fifo = a:fifo if exists('*job_start') - let g:fzf_ipc.job = job_start( - \ ['cat', g:fzf_ipc.fifo], - \ {'out_cb': { _, msg -> call(Callback, [msg]) }, - \ 'exit_cb': { _, status -> status == 0 ? fzf#vim#ipc#restart() : '' }} + let chan.job = job_start( + \ ['cat', fifo], + \ {'out_cb': { _, msg -> has_key(s:channels, fifo) ? call(Callback, [msg]) : '' }, + \ 'exit_cb': { _, status -> status == 0 && has_key(s:channels, fifo) ? fzf#vim#ipc#restart(fifo) : s:drop(fifo) }} \ ) else - let eof = [''] - let g:fzf_ipc.job = jobstart( - \ ['cat', g:fzf_ipc.fifo], + let chan.job = jobstart( + \ ['cat', fifo], \ {'stdout_buffered': 1, - \ 'on_stdout': { j, msg, e -> msg != eof ? call(Callback, msg) : '' }, - \ 'on_exit': { j, status, e -> status == 0 ? fzf#vim#ipc#restart() : '' }} + \ 'on_stdout': { j, msg, e -> has_key(s:channels, fifo) ? s:deliver(Callback, msg) : '' }, + \ 'on_exit': { j, status, e -> status == 0 && has_key(s:channels, fifo) ? fzf#vim#ipc#restart(fifo) : s:drop(fifo) }} \ ) endif endfunction -function! fzf#vim#ipc#stop() - if !exists('g:fzf_ipc') +" Whether the job handle refers to a live job. Nvim returns -1 or 0 instead of +" a handle when it cannot start one +function! s:running(job) + if exists('*job_status') + return job_status(a:job) ==# 'run' + endif + return a:job > 0 +endfunction + +function! fzf#vim#ipc#stop(fifo) + if !has_key(s:channels, a:fifo) return endif - let job = g:fzf_ipc.job - if exists('*job_stop') - call job_stop(job) - else - call jobstop(job) - call jobwait([job]) - endif + " Drop it first, so the exit handler does not restart the job + let chan = remove(s:channels, a:fifo) + " Never throw from here, this runs as part of fzf's exit handling + try + if exists('*job_stop') + call job_stop(chan.job) + else + call jobstop(chan.job) + call jobwait([chan.job]) + endif + catch + endtry - call delete(g:fzf_ipc.fifo) - unlet g:fzf_ipc + call delete(chan.fifo) endfunction diff --git a/doc/fzf-vim.txt b/doc/fzf-vim.txt index 711db4d..532d5d9 100644 --- a/doc/fzf-vim.txt +++ b/doc/fzf-vim.txt @@ -155,6 +155,13 @@ COMMANDS *fzf-vim-commands* command: file path (Files, GFiles, Buffers, History, Locate), line content (Lines, BLines, Marks, Changes), matched text (Rg, Ag, Grep), tag name (Tags, BTags), or commit hash (Commits, BCommits) + - Most commands support CTRL-O to open the current entry in the window fzf + was started from, leaving fzf open, so you can look through several + entries in a single run (Files, GFiles, Buffers, History, Locate, Rg, Ag, + Grep, Lines, BLines, Tags, BTags, Marks, Changes). Requires fzf 0.74.4 or + later, and is not offered in fullscreen mode, with a layout that leaves no + other window (enew, tabnew), on Windows, or when the key is already used + by g:fzf_vim.paste_key or g:fzf_action - Bang-versions of the commands (e.g. `Ag!`) will open fzf in fullscreen - You can set `g:fzf_vim.command_prefix` to give the same prefix to the commands - e.g. `let g:fzf_vim.command_prefix = 'Fzf'` and you have `FzfFiles`, etc. @@ -241,6 +248,26 @@ buffer instead of opening them. Customize it with `g:fzf_vim.paste_key`. let g:fzf_vim.paste_key = 'alt-enter' < +Show key~ + *fzf-vim-show-key* + *g:fzf_vim.show_key* + +Most commands support a key that opens the current entry in the window fzf was +started from and leaves fzf open, so you can look through several entries in +one run. It is labelled Show in the footer to set it apart from Enter, which +opens the entry and closes fzf. Customize it with `g:fzf_vim.show_key`, or set +it to an empty string to turn the key off. A comma separated list binds every +key in it. It takes the key over from `$FZF_DEFAULT_OPTS`, so pick another key +or turn it off to keep a binding of your own. +> + " Key to open the current entry, leaving fzf open + " (default: 'ctrl-o') + let g:fzf_vim.show_key = 'ctrl-o' + + " Use CTRL-O and double-click + let g:fzf_vim.show_key = 'ctrl-o,double-click' +< + Command-level options~ *fzf-vim-command-level-options*