From 8807db82d121d2d7c4a7d313019ae93dff4e9efc Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Thu, 20 Aug 2026 20:20:21 +0900 Subject: [PATCH] Run fzf asynchronously in the Vim plugin Popup mode held the fzf process with system(), which froze Vim until fzf exited. fzf in a popup draws in a pane of its own, so that process only waits for it and does not need a window. Hold it with a job instead and Vim keeps processing its event loop, which is what a live preview needs. Nothing is displayed for the job. Falls back to the blocking path when the job cannot start, so the sink still runs and temp files are removed. - job_start() sets $TERM=dumb and the popup inherits the environment, so fzf dropped to its 16-color scheme. Restore it via 'env', or in the command itself before 8.0.902, when 'env' was added - Fullscreen now uses a terminal buffer in a new tab on Vim too. use_term lacked parentheses, so && bound tighter than || and the layout test was dead on Neovim, which already behaved this way - fzf#run returns an empty list in these modes. Callers use sink, sinklist or exit, and the vader specs now wait for completion - Append --no-tmux only when the spec asks for a Vim window, so --popup in $FZF_DEFAULT_OPTS survives a spec with no layout option Accept popup as a synonym of the tmux layout key, matching --popup being the name of --tmux. popup wins when both are given. s:tmux_enabled(): - Accept $ZELLIJ, which --popup covers as well - Parse tmux -V with matchstr and compare with s:compare_versions. The old string comparison against 'tmux 1.7' misreads 10.0 - Drop the fzf-tmux requirement on tmux 3.3 or above, where --tmux needs no script. Removing the script silently disabled popups entirely - Resolve the script where it is used, and anchor the legacy test to ^- so a --tmux value containing a dash, as in 90%,60%,border-native, is not mistaken for a legacy flag --- CHANGELOG.md | 11 ++++ README-VIM.md | 14 +++-- doc/fzf.txt | 13 +++-- plugin/fzf.vim | 133 ++++++++++++++++++++++++++++++++++++++------- test/vim/fzf.vader | 77 ++++++++++++++++++++++---- 5 files changed, 204 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f31f917..8afdb667 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ CHANGELOG ========= +0.74.4 +------ +- Vim plugin + - fzf no longer blocks the editor, so live previews keep working while fzf is open + - `fzf#run` now returns an empty list. Use `sink`, `sinklist`, or `exit` to get the result + - The popup layout now works under Zellij + - Added `popup` as a synonym of the `tmux` layout key + ```vim + let g:fzf_layout = { 'popup': '90%,70%' } + ``` + 0.74.3 ------ - Performance optimizations for non-ASCII input diff --git a/README-VIM.md b/README-VIM.md index 568d276f..70c5b37a 100644 --- a/README-VIM.md +++ b/README-VIM.md @@ -296,7 +296,7 @@ The following table summarizes the available options. | `options` | string/list | Options to fzf | | `dir` | string | Working directory | | `up`/`down`/`left`/`right` | number/string | (Layout) Window position and size (e.g. `20`, `50%`) | -| `tmux` | string | (Layout) `--tmux` options (e.g. `90%,70%`) | +| `popup`/`tmux` | string | (Layout) `--popup` options (e.g. `90%,70%`) | | `window` (Vim 8 / Neovim) | string | (Layout) Command to open fzf window (e.g. `vertical aboveleft 30new`) | | `window` (Vim 8 / Neovim) | dict | (Layout) Popup window settings (e.g. `{'width': 0.9, 'height': 0.6}`) | @@ -458,14 +458,16 @@ endif let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } } ``` -Alternatively, you can make fzf open in a tmux popup window (requires tmux 3.2 -or above) by putting `--tmux` option value in `tmux` key. +Alternatively, you can make fzf open in a popup window (requires tmux 3.3 or +above, or Zellij 0.44 or above) by putting `--popup` option value in `popup` +key. `tmux` is accepted as a synonym, just as `--tmux` is an alias of +`--popup`. ```vim -" See `--tmux` option in `man fzf` for available options +" See `--popup` option in `man fzf` for available options " [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]] -if exists('$TMUX') - let g:fzf_layout = { 'tmux': '90%,70%' } +if exists('$TMUX') || exists('$ZELLIJ') + let g:fzf_layout = { 'popup': '90%,70%' } else let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } } endif diff --git a/doc/fzf.txt b/doc/fzf.txt index eacc24cf..46206282 100644 --- a/doc/fzf.txt +++ b/doc/fzf.txt @@ -310,7 +310,7 @@ The following table summarizes the available options. `options` | string/list | Options to fzf `dir` | string | Working directory `up` / `down` / `left` / `right` | number/string | (Layout) Window position and size (e.g. `20` , `50%` ) - `tmux` | string | (Layout) `--tmux` options (e.g. `90%,70%` ) + `popup` / `tmux` | string | (Layout) `--popup` options (e.g. `90%,70%` ) `window` (Vim 8 / Neovim) | string | (Layout) Command to open fzf window (e.g. `vertical aboveleft 30new` ) `window` (Vim 8 / Neovim) | dict | (Layout) Popup window settings (e.g. `{'width': 0.9, 'height': 0.6}` ) ---------------------------+---------------+---------------------------------------------------------------------- @@ -467,13 +467,14 @@ in Neovim. " - 'rounded' / 'sharp' / 'horizontal' / 'vertical' / 'top' / 'bottom' / 'left' / 'right' let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } } < -Alternatively, you can make fzf open in a tmux popup window (requires tmux 3.2 -or above) by putting `--tmux` options in `tmux` key. +Alternatively, you can make fzf open in a popup window (requires tmux 3.3 or +above, or Zellij 0.44 or above) by putting `--popup` options in `popup` key. +`tmux` is accepted as a synonym, just as `--tmux` is an alias of `--popup`. > - " See `--tmux` option in `man fzf` for available options + " See `--popup` option in `man fzf` for available options " [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]] - if exists('$TMUX') - let g:fzf_layout = { 'tmux': '90%,70%' } + if exists('$TMUX') || exists('$ZELLIJ') + let g:fzf_layout = { 'popup': '90%,70%' } else let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } } endif diff --git a/plugin/fzf.vim b/plugin/fzf.vim index 677f3560..5511b2c3 100644 --- a/plugin/fzf.vim +++ b/plugin/fzf.vim @@ -127,7 +127,7 @@ function! s:fzf_tempname() return s:fzf_call('tempname') endfunction -let s:layout_keys = ['window', 'tmux', 'up', 'down', 'left', 'right'] +let s:layout_keys = ['window', 'popup', 'tmux', 'up', 'down', 'left', 'right'] let s:fzf_go = s:base_dir.'/bin/fzf' let s:fzf_tmux = s:base_dir.'/bin/fzf-tmux' @@ -255,8 +255,30 @@ function! fzf#exec(...) return s:exec endfunction +" Path to the fzf-tmux script, or an empty string if it is not available. Only +" the legacy options still need it. --tmux is handled by fzf itself. +function! s:fzf_tmux_script() + if !executable(s:fzf_tmux) + if !executable('fzf-tmux') + return '' + endif + let s:fzf_tmux = 'fzf-tmux' + endif + return s:fzf_tmux +endfunction + function! s:tmux_enabled() - if has('gui_running') || !exists('$TMUX') + if has('gui_running') + return 0 + endif + + " --tmux covers Zellij as well, where neither the fzf-tmux script nor the + " tmux version is relevant + if exists('$ZELLIJ') + return 1 + endif + + if !exists('$TMUX') return 0 endif @@ -265,16 +287,21 @@ function! s:tmux_enabled() endif let s:tmux = 0 - if !executable(s:fzf_tmux) - if executable('fzf-tmux') - let s:fzf_tmux = 'fzf-tmux' - else - return 0 - endif + let output = system('tmux -V') + if v:shell_error + return s:tmux + endif + " e.g. 'tmux 3.7b', 'tmux next-3.8' + let ver = matchstr(output, '\d\+\.\d\+') + + " --tmux requires tmux 3.3 or above, and needs no fzf-tmux script + if s:compare_versions(ver, '3.3') >= 0 + let s:tmux = 1 + return s:tmux endif - let output = system('tmux -V') - let s:tmux = !v:shell_error && output >= 'tmux 1.7' + " Older versions still go through the script + let s:tmux = !empty(s:fzf_tmux_script()) && s:compare_versions(ver, '1.7') >= 0 return s:tmux endfunction @@ -502,6 +529,11 @@ try let [shell, shellslash, shellcmdflag, shellxquote] = s:use_sh() let dict = exists('a:1') ? copy(a:1) : {} + " 'popup' and 'tmux' are synonyms, as --popup and --tmux are. Normalize here + " so that the rest of the function only has to know about one of them. + if has_key(dict, 'popup') + let dict.tmux = remove(dict, 'popup') + endif let temps = { 'result': s:fzf_tempname() } let optstr = s:evaluate_opts(get(dict, 'options', '')) try @@ -539,16 +571,19 @@ try \ executable('tput') && filereadable('/dev/tty') let has_vim8_term = has('terminal') && has('patch-8.0.995') let has_nvim_term = has('nvim-0.2.1') || has('nvim') && !s:is_win - let use_term = has_nvim_term || has_vim8_term - \ && !s:need_cmd_window - \ && (has('gui_running') || s:is_win || s:present(dict, 'down', 'up', 'left', 'right', 'window')) + let use_term = (has_nvim_term || has_vim8_term) && !s:need_cmd_window let use_tmux = (has_key(dict, 'tmux') || (!use_height && !use_term || prefer_tmux) && !has('win32unix') && s:splittable(dict)) && s:tmux_enabled() if prefer_tmux && use_tmux let use_height = 0 let use_term = 0 endif if use_term - let optstr .= ' --no-height --no-tmux' + let optstr .= ' --no-height' + " Cancel a --popup from $FZF_DEFAULT_OPTS only when the spec asks for a Vim + " window. Without a layout option, respect the user's preference. + if s:present(dict, 'window', 'up', 'down', 'left', 'right') + let optstr .= ' --no-tmux' + endif elseif use_height let height = s:calc_size(&lines, dict.down, dict) let optstr .= ' --no-tmux --height='.height @@ -568,8 +603,12 @@ try return s:execute_term(dict, command, temps) endif - let lines = use_tmux ? s:execute_tmux(dict, command, temps) - \ : s:execute(dict, command, use_height, temps) + " s:execute_tmux may run fzf asynchronously, so it calls s:callback itself + if use_tmux + return s:execute_tmux(dict, command, temps) + endif + + let lines = s:execute(dict, command, use_height, temps) call s:callback(dict, lines) return lines finally @@ -597,10 +636,15 @@ function! s:fzf_tmux(dict) endfor endif - " Legacy fzf-tmux options - if size =~ '-' + " Legacy fzf-tmux options are flags. A --tmux value never starts with a dash, + " but may contain one, as in '90%,60%,border-native' + if size =~ '^-' + let script = s:fzf_tmux_script() + if empty(script) + throw 'fzf-tmux not found, required for the legacy option: ' . size + endif return printf('LINES=%d COLUMNS=%d %s %s %s --', - \ &lines, &columns, fzf#shellescape(s:fzf_tmux), size, (has_key(a:dict, 'source') ? '' : '-')) + \ &lines, &columns, fzf#shellescape(script), size, (has_key(a:dict, 'source') ? '' : '-')) end " Using native --tmux option @@ -742,6 +786,42 @@ function! s:execute(dict, command, use_height, temps) abort return s:exit_handler(a:dict, exit_status, command) < 2 ? lines : [] endfunction +" Returns 0 if the job could not be started +function! s:start_popup_job(dict, command, temps) abort + let fzf = { 'dict': a:dict, 'temps': a:temps, 'command': a:command } + " Vim passes (job, status) and Nvim (id, status, event) + function! fzf.on_exit(id, code, ...) abort + redraw! + let lines = s:collect(self.temps) + if s:exit_handler(self.dict, a:code, self.command, 1) >= 2 + return + endif + call s:pushd(self.dict) + call s:callback(self.dict, lines) + endfunction + + if has('nvim') + return jobstart([&shell, &shellcmdflag, a:command], fzf) > 0 + endif + + " The command redirects to the result file, so no stream is of interest + let opts = { 'exit_cb': function(fzf.on_exit), + \ 'in_io': 'null', 'out_io': 'null', 'err_io': 'null' } + + " job_start() gives the child $TERM=dumb, and the popup inherits the + " environment from here, so fzf would lose its colors. 'env' was added in + " 8.0.902, so set it in the command itself on older versions. + let command = a:command + if has('patch-8.0.902') + let opts.env = { 'TERM': $TERM } + elseif !s:is_win + let command = join(['export TERM=' . fzf#shellescape($TERM) . ';', command]) + endif + + let job = job_start([&shell, &shellcmdflag, command], opts) + return job_status(job) !=# 'fail' +endfunction + function! s:execute_tmux(dict, command, temps) abort let command = a:command let cwd = s:pushd(a:dict) @@ -750,11 +830,24 @@ function! s:execute_tmux(dict, command, temps) abort let command = join(['cd', fzf#shellescape(cwd), '&&', command]) endif + " fzf draws in a pane of its own, so the process we start here only waits for + " it. Hold it with a job instead of system() so that Vim keeps processing its + " event loop, and nothing has to be displayed for it. Fall back to the + " blocking path when the job cannot be started, or the sink would never run + " and the temporary files would be left behind. + if (has('nvim') || has('job')) && s:start_popup_job(a:dict, command, a:temps) + " Restore the working directory while fzf runs. on_exit pushes it again + call s:dopopd() + return [] + endif + call system(command) let exit_status = v:shell_error redraw! let lines = s:collect(a:temps) - return s:exit_handler(a:dict, exit_status, command) < 2 ? lines : [] + let lines = s:exit_handler(a:dict, exit_status, command) < 2 ? lines : [] + call s:callback(a:dict, lines) + return lines endfunction function! s:calc_size(max, val, dict) diff --git a/test/vim/fzf.vader b/test/vim/fzf.vader index 4cb57887..28115d0b 100644 --- a/test/vim/fzf.vader +++ b/test/vim/fzf.vader @@ -4,15 +4,44 @@ Execute (Setup): Log 'Test directory: ' . g:dir Save &acd + " fzf#run runs fzf in a terminal buffer and returns immediately, so the + " assertions have to wait for the run to finish. 'exit' is called from the + " same handler that runs the sink, just before it, so once it has fired the + " sink has run too. It also fires when nothing is selected, unlike the sink. + function! g:FzfExit(code) + let g:fzf_done = 1 + endfunction + + " Collects the output without displacing a 'sink' the spec already has. + " s:callback runs both + function! g:FzfCollect(lines) + call extend(g:fzf_lines, a:lines) + endfunction + + function! g:FzfRun(spec) abort + let g:fzf_done = 0 + let g:fzf_lines = [] + call fzf#run(extend(copy(a:spec), + \ { 'exit': function('g:FzfExit'), 'sinklist': function('g:FzfCollect') })) + let started = reltime() + while !g:fzf_done && reltimefloat(reltime(started)) < 10 + sleep 10m + endwhile + if !g:fzf_done + throw 'Timed out waiting for fzf#run' + endif + return g:fzf_lines + endfunction + Execute (fzf#run with dir option): let cwd = getcwd() - let result = fzf#run({ 'source': 'git ls-files', 'options': '--filter=vdr', 'dir': g:dir }) + let result = g:FzfRun({ 'source': 'git ls-files', 'options': '--filter=vdr', 'dir': g:dir }) AssertEqual ['fzf.vader'], result AssertEqual 0, haslocaldir() AssertEqual getcwd(), cwd execute 'lcd' fnameescape(cwd) - let result = sort(fzf#run({ 'source': 'git ls-files', 'options': '--filter e', 'dir': g:dir })) + let result = sort(g:FzfRun({ 'source': 'git ls-files', 'options': '--filter e', 'dir': g:dir })) AssertEqual ['fzf.vader'], result AssertEqual 1, haslocaldir() AssertEqual getcwd(), cwd @@ -22,32 +51,32 @@ Execute (fzf#run with Funcref command): function! g:FzfTest(e) call add(g:ret, a:e) endfunction - let result = sort(fzf#run({ 'source': 'git ls-files', 'sink': function('g:FzfTest'), 'options': '--filter e', 'dir': g:dir })) + let result = sort(g:FzfRun({ 'source': 'git ls-files', 'sink': function('g:FzfTest'), 'options': '--filter e', 'dir': g:dir })) AssertEqual ['fzf.vader'], result AssertEqual ['fzf.vader'], sort(g:ret) Execute (fzf#run with string source): - let result = sort(fzf#run({ 'source': 'echo hi', 'options': '-f i' })) + let result = sort(g:FzfRun({ 'source': 'echo hi', 'options': '-f i' })) AssertEqual ['hi'], result Execute (fzf#run with list source): - let result = sort(fzf#run({ 'source': ['hello', 'world'], 'options': '-f e' })) + let result = sort(g:FzfRun({ 'source': ['hello', 'world'], 'options': '-f e' })) AssertEqual ['hello'], result - let result = sort(fzf#run({ 'source': ['hello', 'world'], 'options': '-f o' })) + let result = sort(g:FzfRun({ 'source': ['hello', 'world'], 'options': '-f o' })) AssertEqual ['hello', 'world'], result Execute (fzf#run with string source): - let result = sort(fzf#run({ 'source': 'echo hi', 'options': '-f i' })) + let result = sort(g:FzfRun({ 'source': 'echo hi', 'options': '-f i' })) AssertEqual ['hi'], result Execute (fzf#run with dir option and noautochdir): set noacd let cwd = getcwd() - call fzf#run({'source': ['/foobar'], 'sink': 'e', 'dir': '/tmp', 'options': '-1'}) + call g:FzfRun({'source': ['/foobar'], 'sink': 'e', 'dir': '/tmp', 'options': '-1'}) " No change in working directory AssertEqual cwd, getcwd() - call fzf#run({'source': ['/foobar'], 'sink': 'tabe', 'dir': '/tmp', 'options': '-1'}) + call g:FzfRun({'source': ['/foobar'], 'sink': 'tabe', 'dir': '/tmp', 'options': '-1'}) AssertEqual cwd, getcwd() tabclose AssertEqual cwd, getcwd() @@ -55,13 +84,13 @@ Execute (fzf#run with dir option and noautochdir): Execute (Incomplete fzf#run with dir option and autochdir): set acd let cwd = getcwd() - call fzf#run({'source': [], 'sink': 'e', 'dir': '/tmp', 'options': '-0'}) + call g:FzfRun({'source': [], 'sink': 'e', 'dir': '/tmp', 'options': '-0'}) " No change in working directory even if &acd is set AssertEqual cwd, getcwd() Execute (FIXME: fzf#run with dir option and autochdir): set acd - call fzf#run({'source': ['/foobar'], 'sink': 'e', 'dir': '/tmp', 'options': '-1'}) + call g:FzfRun({'source': ['/foobar'], 'sink': 'e', 'dir': '/tmp', 'options': '-1'}) " Working directory changed due to &acd AssertEqual '/foobar', expand('%') AssertEqual '/', getcwd() @@ -69,7 +98,7 @@ Execute (FIXME: fzf#run with dir option and autochdir): Execute (fzf#run with dir option and autochdir when final cwd is same as dir): set acd cd /tmp - call fzf#run({'source': ['/foobar'], 'sink': 'e', 'dir': '/', 'options': '-1'}) + call g:FzfRun({'source': ['/foobar'], 'sink': 'e', 'dir': '/', 'options': '-1'}) " Working directory changed due to &acd AssertEqual '/', getcwd() @@ -150,6 +179,30 @@ Execute (fzf#wrap): let opts = fzf#wrap({}) Assert opts.options =~ '--color=fg:' +Execute (popup is a synonym of tmux): + unlet! g:fzf_layout + + " Treated as a layout option, so g:fzf_layout is not applied on top + let opts = fzf#wrap('foobar', {'popup': '90%,70%'}) + AssertEqual '90%,70%', opts.popup + Assert !has_key(opts, 'window') + + " And stripped in fullscreen, like the others + let opts = fzf#wrap('foobar', {'popup': '90%,70%'}, 1) + Assert !has_key(opts, 'popup') + + " Accepted in g:fzf_layout + let g:fzf_layout = {'popup': '90%,70%'} + let opts = fzf#wrap('foobar') + AssertEqual '90%,70%', opts.popup + unlet g:fzf_layout + + " Listed as a valid key + let g:fzf_layout = {'bogus': 1} + AssertThrows call fzf#wrap('foobar') + Assert g:vader_exception =~ 'popup' + unlet g:fzf_layout + Execute (fzf#shellescape with sh): AssertEqual '''''', fzf#shellescape('', 'sh') AssertEqual '''\''', fzf#shellescape('\', 'sh')