fzf:从候选过滤、预览到可取消的交互选择
fzf 的输出是用户选择,不是无条件参数
fzf 从标准输入读取候选,维护查询和光标状态,按确认或取消动作返回选择与退出码。包装脚本如果只读取标准输出,不检查退出状态,Esc 产生的空结果可能被解释为当前目录;预览命令若带写操作,还会在光标移动时反复制造副作用。
可靠用法要把候选协议、预览权限和确认状态分开。候选通常由 fd、Git 或固定清单生成,fzf 只负责交互过滤;最终命令只有在明确选择且退出成功后才能执行。
这一区分也决定了审计方式:候选生成命令与 fzf 查询可以记录,人的光标移动不必伪装成确定性流程;真正改变系统状态的命令则要在选择完成后单独展示、校验和确认。
安装后先检查终端与版本
macOS、Windows 和 Linux 都可以从官方发行包或包管理器安装 fzf。团队脚本不应假定 shell 已加载按键集成,先用独立进程验证版本和基本输入。
fzf --version
printf '%s\n' alpha beta gamma | fzf --filter beta非交互的 --filter 适合 CI 验证查询语义;真正的 TTY 选择还要检查终端、颜色和按键配置。
正向实验:从仓库安全选出配置文件
在 Bash、zsh、Git Bash 或 WSL 中创建一个临时 Git 仓库:
mkdir fd-fzf-lab && cd fd-fzf-lab
git init -q
mkdir -p 'packages/payment v2' packages/legacy .hidden
printf 'mode: current\n' > 'packages/payment v2/config.yaml'
printf 'mode: old\n' > packages/legacy/config.yaml
printf 'mode: secret\n' > .hidden/config.yaml
printf 'packages/legacy/\n' > .gitignore
git add .gitignore 'packages/payment v2/config.yaml'先不用交互界面,确认 fd 候选语料:
fd --type f --glob 'config.yaml' .预期只输出 packages/payment v2/config.yaml。普通文件过滤掉目录,glob 把模式解释为文件名通配;隐藏目录和 .gitignore 命中的 packages/legacy 默认不出现。fd 搜索路径名,不搜索 mode: current 这段正文。
把 --glob 去掉并不是等价简写。默认正则里的 . 匹配任意字符,且模式默认不锚定;增加一个反例就能看到误报:
printf 'not yaml\n' > 'packages/payment v2/configXyaml'
fd --type f 'config.yaml' .
fd --type f --glob 'config.yaml' .第一条会同时接受 config.yaml 与 configXyaml,第二条只接受字面文件名。若目标是所有 YAML 后缀,--extension yaml 比手写名称正则更能表达字段意图;若目标是完整相对路径,增加 --full-path 后,pattern 的作用域也随之改变。正反结果应进入升级 fixture,避免“候选更多所以更好”的误报漂移。
然后用安全记录分隔进入 fzf:
fd --type f --glob 'config.yaml' --print0 . \
| fzf --read0 --print0 --preview 'sed -n "1,80p;80q" < {}'fd --print0/-0 用 NUL 结束每条路径,fzf --read0 按 NUL 读取,--print0 继续用 NUL 输出选择。空格、制表符甚至文件名中的换行都不再充当记录边界。预览中的 {} 会被 fzf 作为当前记录的 shell 引用形式替换;把它放在输入重定向右侧,可避免以连字符开头的文件名被 sed 当成选项。80q 则让 sed 打印到第 80 行后立即退出,不再继续扫描大文件的剩余内容。
这个命令只把选择写到标准输出,没有修改文件。真正接下游时仍要按 NUL 读取,而不是用命令替换 selected=$(...),因为 shell 变量不能可靠保存 NUL,且命令替换通常会剥离尾部换行。
fzf 预览不是界面装饰,而是反复执行的命令
设置 --preview 后,fzf 会通过 shell 启动外部进程,并在焦点变化时重新执行;旧预览还可能在新预览开始时被终止。也就是说,按十次方向键可能启动十次命令,子进程不能把“被中断”误当成可以提交部分写入。预览必须满足四个条件:只读、有超时或输出上限、不访问敏感凭据、不把候选拼进未经引用的 shell 代码。
一个稳妥起点是只读取有限行数:
fd --type f --print0 . \
| fzf --read0 --print0 \
--preview 'sed -n "1,200p;200q" < {}' \
--preview-window 'right,60%,wrap'安装了 bat 时可使用 bat --color=always --style=numbers --line-range=:200 -- {}。不要把 --preview 放进全局 FZF_DEFAULT_OPTS:fzf 也会过滤进程、历史、主机名等任意文本,文件预览命令收到这些记录时会产生错误甚至意外访问。预览应跟候选生产命令成对配置。
以下做法危险,即使候选来自自己的仓库:
# 不要这样做
fzf --preview 'sh {}'
fzf --preview 'curl -X POST https://example.com/inspect -d @{}'
fzf --preview 'tool --token "$TOKEN" inspect {}'第一条执行候选文件,第二条在移动焦点时上传内容,第三条让子进程和错误日志接触凭据。预览还会形成容量放大:大文件高亮、Git diff、压缩包解压或网络查询随焦点快速重启。限制文件类型、预览行数和进程时长;远程查询先做本地缓存,敏感目录在 fd 阶段就排除。
取消选择必须是一个正常而明确的状态
fzf 正常确认退出 0,没有匹配退出 1,执行错误退出 2,Esc、Ctrl-C 或其他中断退出 130。包装脚本必须同时检查退出状态和输出数量。取消不是“选择了空路径”,更不是“退回当前目录”。
下面的 Bash 函数用临时文件保留 NUL 输出和 fzf 退出码。管道直接接 xargs 会丢失“用户取消”与“候选为空”的业务语义,因此先完成选择,再决定是否执行:
choose_one_file() {
local output status selected
output=$(mktemp)
if fd --type f --print0 . \
| fzf --read0 --print0 --no-multi \
--preview 'sed -n "1,120p;120q" < {}' >"$output"; then
status=0
else
status=$?
fi
if [ "$status" -eq 130 ]; then
echo '已取消,未执行任何动作' >&2
rm -f "$output"
return 130
fi
if [ "$status" -ne 0 ]; then
echo "选择器失败或没有候选,exit=$status" >&2
rm -f "$output"
return "$status"
fi
IFS= read -r -d '' selected <"$output" || {
echo '选择器成功退出但没有返回路径' >&2
rm -f "$output"
return 2
}
rm -f "$output"
printf '%s\0' "$selected"
}调用方也必须保留状态:
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
if choose_one_file >"$tmp"; then
IFS= read -r -d '' selected <"$tmp"
printf '准备打开:%q\n' "$selected"
"${EDITOR:-vi}" -- "$selected"
else
status=$?
[ "$status" -eq 130 ] && exit 0
exit "$status"
fi--no-multi 把输出数量约束为一个;-- 保护连字符开头的路径;编辑器只在确认成功后启动。自动化测试可用空输入验证非零分支,用向 fzf 进程发送中断验证 130 分支,并断言一个哨兵文件没有变化。不要加 --exit-0 或 || true 抹平状态,除非调用方已经用其他字段完整区分了取消、空候选和执行错误。
配置字段怎样改变运行边界
FZF_DEFAULT_COMMAND 只在 fzf 没有标准输入时提供候选生产命令;当 fd ... | fzf 已有管道输入,它不接管候选。FZF_DEFAULT_OPTS 会注入每次 fzf 调用,适合颜色、布局等纯界面参数,不适合预览、--multi、--bind execute(...) 或会改变退出语义的选项。
export FZF_DEFAULT_COMMAND='fd --type f --strip-cwd-prefix'
export FZF_CTRL_T_COMMAND="$FZF_DEFAULT_COMMAND"
export FZF_DEFAULT_OPTS='--height=60% --layout=reverse --border'--multi 允许多选,会把输出从一个路径变成多个记录,下游必须逐条处理并定义部分失败语义;--select-1 在仅有一个候选时自动选择,减少交互却绕过人工确认;--exit-0 改变空结果退出行为,容易让包装脚本误判成功;--bind 'enter:execute(...)' 把执行权限放进选择器内部,绕过外层状态检查和审计。工程脚本优先让 fzf 只输出选择,把执行留在可测试函数中。
在 fzf 0.74.0 与 tmux 3.7+ 的组合里,--popup 默认使用可切换到其他 pane/window 的浮动 pane,而不是严格模态 popup;显式边框样式时才回到 popup。包装器不能把“fzf 仍显示在屏幕上”当作调用方被阻塞或用户仍在当前上下文的证据。真正的同步边界仍是 fzf 进程退出、状态码被检查、NUL 输出被完整读取。
fd 侧的 --max-depth 控制遍历深度,--type 控制对象类型,--extension 和 --glob 控制名称,--exclude 排除高成本或敏感路径,--follow 跟随符号链接。启用 --follow 前要处理环路、越界目录和重复文件;多数源码仓库保持默认不跟随更安全。
把退出码写进包装脚本
选择结果和进程状态必须一起处理。下面的脚本让取消保持为无副作用分支,并用 --read0/--print0 保护换行文件名。
selected="$(git ls-files -z | fzf --read0 --print0 --preview 'sed -n "1,80p" -- {}')"
status=$?
[ "$status" -eq 0 ] || exit "$status"
printf '%s\0' "$selected" | xargs -0 -r -- printf '%s\n'脚本还应限制预览耗时和输出量;网络查询、格式化或删除都不属于预览动作。
清理、回滚与失败证据
实验结束后离开目录并删除 fd-fzf-lab。若设置过环境变量,执行 unset FZF_DEFAULT_COMMAND FZF_CTRL_T_COMMAND FZF_DEFAULT_OPTS;PowerShell 使用 Remove-Item Env:FZF_DEFAULT_COMMAND,Env:FZF_CTRL_T_COMMAND,Env:FZF_DEFAULT_OPTS -ErrorAction SilentlyContinue。卸载使用原安装器,例如 winget uninstall sharkdp.fd、winget uninstall junegunn.fzf 或 brew uninstall fd fzf。
排障时按链路找第一处差异。fd 没输出,检查根目录、pattern 是正则还是 glob、对象类型、隐藏与 ignore;fd -HI 才出现时,分别用 -H 和 -I 定位是哪层过滤。fd 有候选而 fzf 无结果,清空查询并检查 --read0 是否与生产者 --print0 配对。预览报错而选择正常,检查 $SHELL、占位符引用、输入重定向和预览工具是否安装。按 Esc 后仍执行下游,检查包装脚本是否吞掉 130、是否把空字符串替换为 .。路径被拆分,检查整条链是否从生产到消费都保持 NUL,而不是中途进入 shell 变量或逐行文本。
回滚不只是删除配置。若曾把危险 execute 绑定写入 FZF_DEFAULT_OPTS,先从 shell 启动文件移除并开启新 shell;若预览访问了网络或凭据,撤销相关 Token、检查服务端审计记录和终端历史;若下游命令已经修改仓库,先审查 git diff,只回退这次选择产生的文件,不能用破坏性命令覆盖其他人的工作树。
权限、敏感数据、容量与成本
两个工具都以当前进程权限运行。fd --hidden --no-ignore --follow 可以把搜索面扩到密钥、依赖缓存、挂载目录和仓库外符号链接;fzf 预览又会把内容显示在屏幕、录屏、终端日志或远程会话中。企业脚本应使用最小仓库根目录,默认排除密钥扩展名与配置目录,禁用预览中的网络和写操作,并为共享终端、CI artifact 与录屏设置数据分级规则。
容量瓶颈有三处:fd 的目录遍历和元数据读取、fzf 保存与排序候选的内存、预览进程随焦点变化的重复启动。候选达到大型 monorepo 规模时,先用 --type、--extension、--max-depth 和 --exclude 收敛,不要把整个磁盘送进 fzf;预览限制行数和文件大小。性能测量要同时保存候选数、冷/热缓存条件、首屏延迟、峰值内存和预览启动次数,否则“更快”可能只是少遍历了本应命中的路径。性能门槛由代表性仓库基线、交互延迟预算和开发机配置决定,不能用一个固定文件数承诺所有团队。
软件本身没有集中式服务成本,治理成本主要是跨平台版本、shell 差异、脚本维护和误操作恢复。团队应保留包含空格、换行、隐藏、ignore 冲突、连字符开头和取消选择的 fixture;升级 fd、fzf 或 shell 后自动回归。配置 owner 评审 .ignore/.fdignore 与全局环境变量,任何 execute 绑定、符号链接跟随或多选写操作都需要单独安全审查。
选型时看候选从哪里来、最后要做什么
按路径名、扩展名、类型和深度发现本地文件时选 fd;已有任意文本候选、需要人快速收敛时选 fzf;两者组合适合交互导航、挑选测试、打开配置和生成只读候选清单。候选必须严格等于 Git 索引时,用 git ls-files -z 取代 fd;需要搜索文件正文时用 rg;需要符号身份和调用关系时用 LSP;无人值守批处理则通常不应依赖交互式 fzf,而应使用可审计的确定性清单。
成熟的组合不是一条炫目的管道,而是一份状态契约:生产者决定哪些路径有资格进入,NUL 保证路径身份不被文本分隔破坏,预览只帮助判断,fzf 的退出状态表达确认或取消,外层程序最后才获得执行权。只要其中一层含糊,模糊查找就会把效率问题升级成权限和数据问题。
出现版本差异时,先以目标二进制的 fd --help、fzf --man 为字段证据,再查 fd Releases、fzf Releases 与 fzf 官方使用说明。帮助文本决定当前机器究竟支持哪些参数,发布说明用于发现行为变化;团队脚本不能因上游文档已经更新,就假定发行版仓库里的旧二进制也有同样语义。
