From c2b97c62b908ee72397bc262e29c34b8089d1390 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sun, 31 May 2026 23:24:35 +0900 Subject: [PATCH] Add ALT-ENTER to paste selected items into the buffer - New fzf#vim#paste inserts selections instead of opening them - First item after cursor (space-padded), rest on following lines - Leading whitespace stripped only when pasting after text - Commands opt in via `_paste`; key configurable with g:fzf_vim.paste_key - Skip the key when buffer is not &modifiable - Per command, paste: - path: Files, GFiles, GFiles?, Buffers, History, Locate - line text: Lines, BLines, Marks, Changes - matched text: Rg, Ag, Grep - tag name: Tags, BTags - commit hash: Commits, BCommits --- README.md | 17 +++++ autoload/fzf/vim.vim | 152 +++++++++++++++++++++++++++++++++++++++++-- doc/fzf-vim.txt | 19 +++++- 3 files changed, 183 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 52d8f57..3d6bf32 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,12 @@ Commands - Most commands support `CTRL-T` / `CTRL-X` / `CTRL-V` key bindings to open in a new tab, a new split, or in a new vertical split +- Most commands support `ALT-ENTER` to insert the selected items into the + current buffer instead of opening them. The inserted text depends on the + 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`) - 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. @@ -156,6 +162,17 @@ let g:fzf_vim.preview_window = [] " let g:fzf_vim.preview_bash = 'C:\Git\bin\bash.exe' ``` +#### Paste key + +Most commands support a key to insert the selected items into the current +buffer instead of opening them (see the command list above for what each +command inserts). Customize it with `g:fzf_vim.paste_key`. + +```vim +" Key to insert the selected items into the current buffer (default: 'alt-enter') +let g:fzf_vim.paste_key = 'alt-enter' +``` + #### Command-level options ```vim diff --git a/autoload/fzf/vim.vim b/autoload/fzf/vim.vim index ff43c56..e96b8e0 100755 --- a/autoload/fzf/vim.vim +++ b/autoload/fzf/vim.vim @@ -233,17 +233,54 @@ function! s:reverse_list(opts) return a:opts endfunction +" Call fzf#wrap with g:fzf_action temporarily replaced by a:actions, restoring +" it afterwards. fzf#wrap derives --expect from g:fzf_action, so this controls +" which keys are bound for a given command. +function! s:wrap_with_action(actions, ...) + let had_action = exists('g:fzf_action') + let saved_action = had_action ? g:fzf_action : 0 + let g:fzf_action = a:actions + try + return call('fzf#wrap', a:000) + finally + if had_action + let g:fzf_action = saved_action + else + unlet g:fzf_action + endif + endtry +endfunction + function! s:wrap(name, opts, bang) " fzf#wrap does not append --expect if sink or sink* is found let opts = copy(a:opts) + " `_paste` opts in to the paste key (s:paste_key()). Sink-less commands have it + " dispatched by the base s:common_sink; custom-sink commands keep the key in + " --expect and project the selection themselves. Skip it when the current + " buffer cannot be modified, as there is nothing to paste into. + let paste = get(opts, '_paste', 0) && &modifiable + silent! call remove(opts, '_paste') let options = '' if has_key(opts, 'options') let options = type(opts.options) == s:TYPE.list ? join(opts.options) : opts.options endif + let action = get(g:, 'fzf_action', s:default_action) if options !~ '--expect' && has_key(opts, 'sink*') let Sink = remove(opts, 'sink*') - let wrapped = fzf#wrap(a:name, opts, a:bang) + " A custom sink routes the pressed key through s:action_for, which only + " honors string actions. Expose only the string actions so that funcref + " actions are not bound here. When the command opts in to paste, keep the + " paste key so the sink can handle it. + let actions = filter(copy(action), 'type(v:val) == s:TYPE.string') + if paste + let actions[s:paste_key()] = '' + endif + let wrapped = s:wrap_with_action(actions, a:name, opts, a:bang) let wrapped['sink*'] = Sink + elseif paste + " Sink-less command: let the base s:common_sink dispatch the paste funcref. + let actions = extend(copy(action), {s:paste_key(): function('fzf#vim#paste')}) + let wrapped = s:wrap_with_action(actions, a:name, opts, a:bang) else let wrapped = fzf#wrap(a:name, opts, a:bang) endif @@ -352,6 +389,54 @@ let s:default_action = { \ 'ctrl-x': 'split', \ 'ctrl-v': 'vsplit' } +" Key that pastes the selected items into the current buffer instead of opening +" them. Commands opt in by setting `_paste` on the spec (see s:wrap), and their +" sink projects each selected entry to the text to paste before calling +" fzf#vim#paste(). Configurable via g:fzf_vim.paste_key. +function! s:paste_key() + return s:conf('paste_key', 'alt-enter') +endfunction + +" Insert the given items into the current buffer. The first item is inserted +" after the cursor (so that it works even at the end of the line); a single +" space is added before it when the preceding text does not already end with a +" whitespace. Remaining items are appended on the following lines, preserving +" their leading whitespace so that copied blocks keep their indentation. When +" pasting after some text, the first item's leading whitespace is stripped. +function! fzf#vim#paste(items) abort + if empty(a:items) + return + endif + let line = getline('.') + let idx = empty(line) ? 0 : col('.') + let head = strpart(line, 0, idx) + let tail = strpart(line, idx) + let pad = (!empty(head) && head !~ '\s$') ? ' ' : '' + let first = empty(head) ? a:items[0] : substitute(a:items[0], '^\s*', '', '') + let first = pad . first + call setline('.', head . first . tail) + call cursor(line('.'), idx + strlen(first)) + if len(a:items) > 1 + call append(line('.'), a:items[1:]) + endif +endfunction + +" True when the selected lines (as passed to a sink*) request a paste. +function! s:is_paste(lines) + return get(a:lines, 0, '') ==# s:paste_key() +endfunction + +" Drop the first `count` whitespace-separated columns from `line` and return +" the rest, stripped. Used to extract the line text from formatted entries +" (e.g. Marks: `mark line col text`, Changes: `buf offset line col text`). +function! s:rest_after_columns(line, count) + let rest = a:line + for _ in range(a:count) + let rest = substitute(rest, '^\s*\S\+', '', '') + endfor + return s:strip(rest) +endfunction + function! s:execute_silent(cmd) silent keepjumps keepalt execute a:cmd endfunction @@ -460,6 +545,7 @@ function! fzf#vim#files(dir, ...) endif let args.options = ['--scheme', 'path', '-m', '--prompt', strwidth(dir) < &columns / 2 - 20 ? dir : '> '] + let args._paste = 1 call s:merge_opts(args, s:conf('files_options', [])) return s:fzf('files', args, a:000) endfunction @@ -478,6 +564,10 @@ function! s:line_handler(lines) call add(qfl, {'bufnr': str2nr(chunks[0]), 'lnum': str2nr(chunks[2]), 'text': join(chunks[3:], "\t")}) endfor + if s:is_paste(a:lines) + return fzf#vim#paste(map(copy(qfl), 's:rstrip(v:val.text)')) + endif + call s:action_for(a:lines[0]) if !s:fill_quickfix('lines', qfl) let chunks = split(a:lines[1], '\t') @@ -534,6 +624,7 @@ function! fzf#vim#lines(...) return s:fzf('lines', { \ 'source': lines, \ 'sink*': s:function('s:line_handler'), + \ '_paste': 1, \ 'options': s:reverse_list(['--tiebreak=index', '--prompt', 'Lines> ', '--ansi', '--extended', '--nth='.nth.'..', '--tabstop=1', '--query', query, '--multi']) \}, args) endfunction @@ -552,6 +643,9 @@ function! s:buffer_line_handler(lines) let ltxt = join(chunks[1:], "\t") call add(qfl, {'filename': expand('%'), 'lnum': str2nr(ln), 'text': ltxt}) endfor + if s:is_paste(a:lines) + return fzf#vim#paste(map(copy(qfl), 's:rstrip(v:val.text)')) + endif call s:action_for(a:lines[0]) if !s:fill_quickfix('blines', qfl) execute split(a:lines[1], '\t')[0] @@ -575,6 +669,7 @@ function! fzf#vim#buffer_lines(...) return s:fzf('blines', { \ 'source': s:buffer_lines(query), \ 'sink*': s:function('s:buffer_line_handler'), + \ '_paste': 1, \ 'options': s:reverse_list(['+m', '--tiebreak=index', '--multi', '--prompt', 'BLines> ', '--ansi', '--extended', '--nth=2..', '--tabstop=1']) \}, args) endfunction @@ -630,6 +725,7 @@ endfunction function! fzf#vim#locate(query, ...) return s:fzf('locate', { \ 'source': 'locate '.a:query, + \ '_paste': 1, \ 'options': '-m --prompt "Locate> "' \}, a:000) endfunction @@ -705,6 +801,7 @@ endfunction function! fzf#vim#history(...) return s:fzf('history-files', { \ 'source': fzf#vim#_recent_files(), + \ '_paste': 1, \ 'options': ['-m', '--header-lines', !empty(expand('%')), '--prompt', 'Hist> '] \}, a:000) endfunction @@ -754,6 +851,7 @@ function! fzf#vim#gitfiles(args, ...) return s:fzf('gfiles', { \ 'source': source, \ 'dir': root, + \ '_paste': 1, \ 'options': '--scheme path -m --read0 --prompt "GitFiles> "' \}, a:000) endif @@ -769,7 +867,11 @@ function! fzf#vim#gitfiles(args, ...) \ ? diff_prefix . 'diff -- {-1} ' . bar . ' delta --width $FZF_PREVIEW_COLUMNS --file-style=omit ' . bar . ' sed 1d' \ : diff_prefix . 'diff --color=always -- {-1} ' . bar . ' sed 1,4d', \ s:escape_for_bash(s:bin.preview)) - let wrapped = fzf#wrap({ + " Expose the paste funcref so that fzf#wrap binds the paste key in --expect + " and the common sink dispatches it (with the stripped paths via newsink). + let action = get(g:, 'fzf_action', s:default_action) + let actions = &modifiable ? extend(copy(action), {s:paste_key(): function('fzf#vim#paste')}) : action + let wrapped = s:wrap_with_action(actions, { \ 'source': prefix . '-c color.status=always status --short --untracked-files=all', \ 'dir': root, \ 'options': ['--scheme', 'path', '--ansi', '--multi', '--nth', '2..,..', '--tiebreak=index', '--prompt', 'GitFiles?> ', '--preview', preview] @@ -811,6 +913,10 @@ function! s:bufopen(lines) if len(a:lines) < 2 return 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\\]'))), ':~:.')")) + endif let b = matchstr(a:lines[1], '\[\zs[0-9]*\ze\]') if empty(a:lines[0]) && s:conf('buffers_jump', 0) let [t, w] = s:find_open_window(b) @@ -883,6 +989,7 @@ function! fzf#vim#buffers(...) \ 'source': map(sorted, 'fzf#vim#_format_buffer(v:val)'), \ 'sink*': s:function('s:bufopen'), \ 'exit': s:function('s:buffers_exit'), + \ '_paste': 1, \ 'options': options \} return s:fzf('buffers', spec, args) @@ -924,6 +1031,10 @@ function! s:ag_handler(name, lines) return endif + if s:is_paste(a:lines) + return fzf#vim#paste(map(copy(list), 's:rstrip(v:val.text)')) + endif + call s:action_for(a:lines[0], list[0].filename, len(list) > 1) if s:fill_quickfix(a:name, list) return @@ -999,6 +1110,7 @@ function! fzf#vim#grep(grep_command, ...) call remove(args, 0) endif + let opts._paste = 1 function! opts.sink(lines) closure return s:ag_handler(get(opts, 'name', name), a:lines) endfunction @@ -1044,6 +1156,7 @@ function! fzf#vim#grep2(command_prefix, query, ...) if len(args) && type(args[0]) == s:TYPE.bool call remove(args, 0) endif + let opts._paste = 1 function! opts.sink(lines) closure return s:ag_handler(name, a:lines) endfunction @@ -1077,6 +1190,9 @@ function! s:btags_sink(lines) if len(a:lines) < 2 return endif + if s:is_paste(a:lines) + return fzf#vim#paste(map(a:lines[1:], 's:strip(split(v:val, "\t")[0])')) + endif call s:action_for(a:lines[0]) let qfl = [] for line in a:lines[1:] @@ -1110,6 +1226,7 @@ function! fzf#vim#buffer_tags(query, ...) return s:fzf('btags', { \ 'source': s:btags_source(tag_cmds), \ 'sink*': s:function('s:btags_sink'), + \ '_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 return s:warn(v:exception) @@ -1124,6 +1241,10 @@ function! s:tags_sink(lines) return endif + if s:is_paste(a:lines) + return fzf#vim#paste(map(a:lines[1:], 's:strip(split(v:val, "\t")[0])')) + endif + " Remember the current position let buf = bufnr('') let view = winsaveview() @@ -1214,6 +1335,7 @@ 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'), + \ '_paste': 1, \ 'options': extend(opts, ['--nth', '1..2', '-m', '-d', '\t', '--tiebreak=begin', '--prompt', 'Tags> ', '--query', a:query])}, a:000) endfunction @@ -1325,6 +1447,11 @@ function! s:changes_sink(lines) return endif + if s:is_paste(a:lines) + " buf offset line col text -> the text column + return fzf#vim#paste(map(a:lines[1:], 's:rest_after_columns(v:val, 4)')) + endif + call s:action_for(a:lines[0]) let [b, o, l, c] = split(a:lines[1])[0:3] @@ -1365,6 +1492,7 @@ function! fzf#vim#changes(...) return s:fzf('changes', { \ 'source': all_changes, \ 'sink*': s:function('s:changes_sink'), + \ '_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 --inline-info --no-separator', cursor)}, a:000) endfunction @@ -1379,6 +1507,10 @@ function! s:mark_sink(lines) if len(a:lines) < 2 return endif + if s:is_paste(a:lines) + " mark line col file/text -> the file/text column + return fzf#vim#paste(map(a:lines[1:], 's:rest_after_columns(v:val, 3)')) + endif call s:action_for(a:lines[0]) execute 'normal! `'.matchstr(a:lines[1], '\S').'zz' endfunction @@ -1401,6 +1533,7 @@ 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'), + \ '_paste': 1, \ 'options': '+m -x --ansi --tiebreak=index --header-lines 1 --tiebreak=begin --prompt "Marks> " --list-border --header-border inline --inline-info --no-separator'}, extra) endfunction @@ -1555,6 +1688,10 @@ function! s:commits_sink(lines) return s:yank_to_register(hashes) end + if s:is_paste(a:lines) + return fzf#vim#paste(filter(map(a:lines[1:], 'matchstr(v:val, pat)'), 'len(v:val)')) + endif + let diff = a:lines[0] == 'ctrl-d' let Cmd = get(get(g:, 'fzf_action', s:default_action), a:lines[0], '') let cmd = type(Cmd) == s:TYPE.string ? Cmd : '' @@ -1616,7 +1753,14 @@ function! s:commits(range, buffer_local, args) let command = 'Commits' endif - let expect_keys = join(keys(get(g:, 'fzf_action', s:default_action)), ',') + " Only string actions are meaningful for the commits sink; the paste key is + " handled explicitly in s:commits_sink (pastes the commit hashes), and only + " when the current buffer can be modified. + let action = get(g:, 'fzf_action', s:default_action) + let expect_keys = filter(keys(action), 'type(action[v:val]) == s:TYPE.string') + if &modifiable + call add(expect_keys, s:paste_key()) + endif let options = { \ 'source': source, \ 'sink*': s:function('s:commits_sink'), @@ -1624,7 +1768,7 @@ function! s:commits(range, buffer_local, args) \ '--inline-info', '--prompt', command.'> ', '--bind=ctrl-s:toggle-sort', \ '--header-border=horizontal', '--no-separator', \ '--header', '• Press '.s:magenta('CTRL-S', 'Special').' to toggle sort, '.s:magenta('CTRL-Y', 'Special').' to yank commit hashes', - \ '--expect=ctrl-y,'.expect_keys]) + \ '--expect=ctrl-y,'.join(expect_keys, ',')]) \ } if a:buffer_local diff --git a/doc/fzf-vim.txt b/doc/fzf-vim.txt index bae5ff3..711db4d 100644 --- a/doc/fzf-vim.txt +++ b/doc/fzf-vim.txt @@ -1,4 +1,4 @@ -fzf-vim.txt fzf-vim Last change: June 8 2025 +fzf-vim.txt fzf-vim Last change: May 31 2026 FZF-VIM - TABLE OF CONTENTS *fzf-vim* *fzf-vim-toc* ============================================================================== @@ -150,6 +150,11 @@ COMMANDS *fzf-vim-commands* - Most commands support CTRL-T / CTRL-X / CTRL-V key bindings to open in a new tab, a new split, or in a new vertical split + - Most commands support ALT-ENTER to insert the selected items into the + current buffer instead of opening them. The inserted text depends on the + 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) - 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. @@ -224,6 +229,18 @@ behavior with `g:fzf_vim.preview_window`. Here are some examples: " let g:fzf_vim.preview_bash = 'C:\Git\bin\bash.exe' < +Paste key~ + *fzf-vim-paste-key* + *g:fzf_vim.paste_key* + +Most commands support a key to insert the selected items into the current +buffer instead of opening them. Customize it with `g:fzf_vim.paste_key`. +> + " Key to insert the selected items into the current buffer + " (default: 'alt-enter') + let g:fzf_vim.paste_key = 'alt-enter' +< + Command-level options~ *fzf-vim-command-level-options*