动态切换 fzf 源之 mru/files 源切换

发布于 2023/12/30, 编辑于 2024/3/28

本文介绍如何在 fzf 中根据搜索输入框有无输入值来动态切换 mru(最近打开的文件列表)和 files(文件搜索),这个操作简单来说就像 VSCode 中的 ctrl-p,当没有任何输入值的时候展示的列表是最近打开的文件列表,当输入了值以后就会变成文件搜索。我们可以利用简单的 shell 脚本来在 fzf 中实现这一操作,本文会使用 neovim + coc.nvim 进行举例

一、工具介绍

※ 主角

fzf:本文的主角,具体是什么我就不多介绍了,相信点进来文章的读者都知道。

※ 辅助工具

以下是用于说明如何实现这一动态切换的辅助工具,非必须,只是为了方便说明,必须的只有 fzf。

  • neovim:本文使用的编辑器,当然你也可以使用 vim,或者其他编辑器或者终端,只要支持 fzf 就行。
  • coc.nvim:一个 neovim 的插件,配合 neovim 使用,提供了很多功能,它可以在 neovim 中打开一个文件的时候把文件路径保存到一个列表文件中,这个列表就是 mru 文件列表。
  • fd:一个高性能文件搜索工具,本文使用它来提供文件搜索功能,当然你也可以使用其他的工具,比如 find、rg 等等。

二、原理说明

2.1. mru 和 files 列表的获取

※ mru

首先你需要一个保存最近打开文件列表的文件实现,例如结合 neovim 和 coc.nvim,我们可以在 neovim 中打开一个文件的时候 coc.nvim 会自动触发一个保存事件,把当前打开的文件的绝对路径保存到一个文件中,这个文件就是 mru 文件列表,格式如下:

~/.config/coc/mru 文件:

...
/home/xxx/.config/nvim/init.vim
/home/xxx/.config/nvim/coc-settings.json
/home/xxx/a.txt
/home/xxx/b.txt
/home/xxx/c.txt
...

※ files

其次你需要一个文件搜索工具,例如 fd,它可以搜索当前目录下的所有文件,然后把搜索结果输出到 stdout。例如 fd 的 fd --type f --hidden 或 find 的 find . -type f

结合 fzf,我们可以把 fd 的搜索结果作为 fzf 的输入,然后 fzf 会把搜索结果展示到 fzf 的界面上,然后我们就可以通过 fzf 的交互来选择我们想要的文件。例如:

fd --type f --hidden | fzf

相信大家都知道如何使用这些工具,这里就不多说了。

2.2. fzf 的 KEY/EVENT BINDINGS

通过 man fzf 我们可以查到和 key/event 相关的配置,其中有这么一段话:

KEY/EVENT BINDINGS
       --bind option allows you to bind a key or an event to one or more actions. You can use it to customize key bindings or implement dynamic behaviors.

       --bind takes a comma-separated list of binding expressions. Each binding expression is KEY:ACTION or EVENT:ACTION.

       e.g.
            fzf --bind=ctrl-j:accept,ctrl-k:kill-line

这段话的意思是说我们可以通过 --bind 来绑定一个 key 或者 event 到一个或者多个 action,这样我们就可以实现一些自定义的功能,比如我们可以绑定一个 key 到一个 shell 脚本,这样当我们按下这个 key 的时候就会执行这个 shell 脚本,这个 shell 脚本可以是任何我们想要执行的命令,比如打开一个文件、执行一个命令等等。

同理通过 --bind 绑定一个 event 到一个 action 也是一样的,只不过 event 是 fzf 内部的事件,比如 change 事件,当搜索输入框的值发生变化的时候就会触发这个事件,我们可以通过绑定这个事件到一个 shell 脚本来实现当搜索输入框的值发生变化的时候执行这个 shell 脚本。

而我们要实现的动态切换 mru 和 files 就是通过绑定 change 事件来实现的,当搜索输入框的值发生变化的时候,我们就会执行一个 shell 脚本,这个 shell 脚本会根据搜索输入框的值来判断是展示 mru 列表还是 files 列表。change 的文档说明是:

change
       Triggered whenever the query string is changed

       e.g.
            # Move cursor to the first entry whenever the query is changed
            fzf --bind change:first

而 action 也是指 fzf 内部的一些动作,比如 accept,当我们按下回车的时候就会触发这个 action,这个 action 会把当前选中的结果输出到 stdout,然后退出 fzf。这里我们需要用的 action 就是 reload,文档的说明是:

RELOAD INPUT
    reload(...) action is used to dynamically update the input list without restarting fzf. I
 takes the same command template with placeholder expressions as execute(...).

    See https://github.com/junegunn/fzf/issues/1750 for more info.

    e.g.
         # Update the list of processes by pressing CTRL-R
         ps -ef | fzf --bind 'ctrl-r:reload(ps -ef)' --header 'Press CTRL-R to reload' \
                      --header-lines=1 --layout=reverse

         # Integration with ripgrep
         RG_PREFIX="rg --column --line-number --no-heading --color=always --smart-case "
         INITIAL_QUERY="foobar"
         FZF_DEFAULT_COMMAND="$RG_PREFIX '$INITIAL_QUERY'" \
           fzf --bind "change:reload:$RG_PREFIX {q} || true" \
               --ansi --disabled --query "$INITIAL_QUERY"

那么我们就可以通过 fzf --bind change:reload(...) 来实现当搜索输入框的值发生变化的时候重新加载输入列表,这样我们就可以在重新加载输入列表的时候根据搜索输入框的值来判断是展示 mru 列表还是 files 列表了。

三、实现

3.1. 初步实现

首先实现一个初步的版本,只在终端中实现切换,不考虑在 neovim 中的实现,主要是为了方便说明。

※ 首先事前工作

  • fzf
  • fd
  • 一份已经存在的 mru 文件列表

详细的上面已经说过了,这里就不再赘述。

※ 切换脚本

~/dynamic_fzf_source.sh 切换脚本:

#!/bin/bash

input=$1 # 获取用户输入

if [ -z "$input" ]; then # 检查是否有任何输入
    perl -ne 'print if !$seen{$_}++' ~/.config/coc/mru # 没有输入时,显示 mru 文件内容
else
    fd --type f --hidden # 有输入时,搜索当前目录
fi

这段脚本的作用就是当没有任何输入的时候显示 mru 文件列表,当有输入的时候搜索当前目录下的所有文件。

而中间那一段 perl 脚本则是用于处理过滤掉 mru 文件中的重复行,因为 mru 文件中可能会有重复的行,这样就会导致 fzf 中展示的列表中有重复的行,所以我们需要过滤掉重复的行,这里使用的是 perl 脚本,当然你也可以使用其他的工具,比如 awk、sort 等等。

※ 绑定 fzf 的 change 事件

fzf --bind "change:reload($HOME/dynamic_fzf_source.sh {q})" < <($HOME/dynamic_fzf_source.sh)

这个命令意思就是当搜索输入框的值发生变化的时候重新加载输入列表,这里的输入列表就是 ~/dynamic_fzf_source.sh 脚本的输出,然后把搜索输入框的值作为参数传递给 ~/dynamic_fzf_source.sh 脚本,这样我们就可以在 ~/dynamic_fzf_source.sh 脚本中根据搜索输入框的值来判断是展示 mru 列表还是 files 列表了。

其中 {q} 是 fzf 用于获取搜索输入框的值的占位符,详细的可以查看 man fzf

< <($HOME/dynamic_fzf_source.sh) 这一段则是把 ~/dynamic_fzf_source.sh 脚本的输出作为 fzf 的输入。意思就是先直接执行 ~/dynamic_fzf_source.sh 脚本(且不传任何参数),然后把它的输出作为 fzf 的输入。

※ 效果截图

当前 mru 列表文件展示:

3.png

无输入值时,fzf mru 展示:

1.png

有输入值时,fzf files 展示:

2.png

3.2. 在 neovim 中实现

※ 首先事前工作

  • fzf
  • neovim
  • fd
  • 一份已经存在的 mru 文件列表

※ 切换脚本

该脚本与上面的脚本几乎一样,但是我额外进行了对 cwd(当前工作目录)的处理,把不在当前工作目录下的文件移除掉,这样就可以使 mru 列表看起来更加简洁。

~/.vim/dynamic_fzf_source.sh 切换脚本:

#!/bin/bash

cwd=$1  # 获取传入的当前工作目录
input=$2 # 获取用户输入

if [ -z "$input" ]; then
    perl -ne 'print substr($_, length("'"$cwd"'/")) if m{^'"$cwd"'/} && !$seen{$_}++' ~/.config/coc/mru
else
    fd --type f --hidden
fi

基本逻辑和上面的脚本一致,只多了一步对 cwd 的处理。

※ 绑定 fzf 的 change 事件

这里需要先在 neovim 中加载 fzf 插件,然后使用以下 vim 代码来构造一个 vim 方法(习惯使用 lua 的同学可以自行转换):

function! s:FZF(...)
  let cwd = getcwd()
  " 这一行的核心代码和上面的命令是一样的都是 `--bind change:reload(...)`
  " 不过多传了一个 `cwd` 参数
  let opts = fzf#wrap('FZF', { 'options': ['--bind=' . 'change:reload($HOME/.vim/dynamic_fzf_source.sh ' . cwd . ' {q})'] })
  " 这一行则是用于替代上面的 `< <($HOME/dynamic_fzf_source.sh)`
  " 因为 vim 中调用无法使用管道传输,所以会先构造一个初始的 source
  let opts.source = "perl -ne 'print substr(\$_, length(\"" . cwd . "/\")) if m{^" . cwd . "/} && !$seen{\$_}++' ~/.config/coc/mru"
  call fzf#run(opts)
endfunction

" 绑定一个快捷键
nnoremap <silent> <leader>f :call <SID>FZF()<CR>

这样我们就可以在 neovim 中使用 <leader>f 来打开 fzf mru/files 了,当然你也可以使用其他的快捷键。

※ 效果截图

无输入值时,fzf mru 展示:

4.png

有输入值时,fzf files 展示:

5.png

四、总结

上面介绍了如何在 fzf 中根据搜索输入框的值来动态切换 mru 和 files 列表,这样我们就可以在 fzf 中实现一个类似 VSCode 中的 ctrl-p 的功能了,当然这只是一个简单的实现,你可以根据自己的需求来进行扩展。

其基本原理使用了 fzf 的 --bind change:reload(...) 来实现。

以上就是本文的全部内容,希望对你有所帮助,如果有任何问题欢迎在评论区留言,我会尽快回复:)。

点击这里前往 Github 查看原文,交流意见~

文档信息

版权声明:自由转载 - 非商用 - 非衍生 - 保持署名(创意共享3.0许可证