告别 zsh,全面切换到 fish
fish 这个名字我其实很早就听说过了。当年刚开始用 oh-my-zsh 的时候,就有了解到 oh-my-zsh 里很多好用的东西,比如根据历史记录给出灰色建议的 autosuggestions,输入时的语法高亮,还有更聪明的补全,其实都是在 zsh 上复刻 fish 自带的能力。
后来从 oh-my-zsh 换到 zinit,为的就是速度。zinit 确实快,但是为了快,配置里全是 wait、lucid、atinit 这种东西,补全还要专门安排 compinit 的时机,隔一段时间就得维护下,且心智负担很重,总是想不起来当时是想做什么😭。
前阵子正好看到 fish 从 4.0 开始已经用 Rust 完全重写了,我对 Rust 写的东西总是多一分好感,于是就装上试了一下。只能说,不愧是大名鼎鼎的 fish,没有任何插件就已经有了我在 zsh 上折腾了好几年才配出来的那些功能,所以没过几天,我就把默认 shell 整个切了过去。
开箱即用
刚装好的 fish,什么配置都没有,就已经有这些东西了。
- 自动建议。根据历史记录和当前目录里的文件,在输入时用灰色字给出建议,按方向键右接受,
Alt + →只接受一个词。 - 语法高亮。命令存在是一种颜色,不存在直接标红,路径存在的话还会加下划线。
- 补全。很多命令的补全是 fish 从 man page 里自动解析出来的,所以连参数说明都有,Tab 一下就能看到。
- 启动速度。不需要延迟加载这一套,打开就是秒开。
这些在 zsh 里分别要靠 zsh-autosuggestions、zsh-syntax-highlighting、zsh-completions 再加上 fzf-tab 才能做到,而且还要花心思安排它们的加载顺序。
安装
我的 Mac 现在是用 nix-darwin 管理的,所以 fish 也由它来装。nix-darwin 的 programs.fish.enable 会安装 fish,并且把 Nix 的环境设置好,environment.shells 则会把它加进 /etc/shells。
{ pkgs, ... }:
{
programs.fish.enable = true;
environment.shells = [ pkgs.fish ];
}
darwin-rebuild switch 之后,再用 chsh 把默认 shell 换过去。
chsh -s /run/current-system/sw/bin/fish
这里用的是 /run/current-system/sw/bin/fish,而不是 /nix/store 里某个具体的路径,因为它是一个始终指向当前系统版本的链接,以后 fish 升级了也不需要重新 chsh。
配置文件的结构
fish 的配置都在 ~/.config/fish 下面,和 zsh 把所有东西塞进一个 .zshrc 不同,它本身就鼓励把配置拆开。
~/.config/fish/
├── config.fish # 主配置
├── conf.d/ # 启动时会自动加载的片段
│ ├── 05-env.fish
│ ├── 10-colors.fish
│ ├── 20-abbrs.fish
│ └── 30-integrations.fish
├── functions/ # 一个文件一个函数,用到的时候才加载
│ ├── dog.fish
│ ├── git-amend-date.fish
│ ├── list_node_modules.fish
│ └── yy.fish
└── fish_plugins # fisher 的插件列表
conf.d 里的文件会按文件名的顺序加载,所以我在前面加上了数字。functions 则是 fish 的自动加载目录,函数第一次被调用的时候,fish 才会去读对应的文件。所以函数写得再多,也不会拖慢启动速度。这和我之前在 zinit 里拆模块的思路差不多,只不过 fish 是原生支持的。
config.fish
主配置里主要是 PATH 和几个工具的初始化。
set -g fish_greeting
fish_add_path -g /run/current-system/sw/bin /nix/var/nix/profiles/default/bin
fish_add_path -g $HOME/bin $HOME/go/bin $HOME/.local/bin $HOME/.cargo/bin
if test -x /opt/homebrew/bin/brew
fish_add_path -g /opt/homebrew/bin /opt/homebrew/sbin
end
set -gx LANG C.UTF-8
set -gx LC_ALL C.UTF-8
set -gx FZF_DEFAULT_COMMAND 'fd --type file --hidden --no-ignore'
if status is-interactive
set -gx GPG_TTY (tty)
if type -q mise
mise activate fish | source
end
if type -q starship
starship init fish | source
end
if type -q zoxide
zoxide init fish | source
end
if type -q direnv
direnv hook fish | source
end
end
set -g fish_greeting 是把 fish 每次启动时打印的那句欢迎语关掉。fish_add_path 会自动去重,而且路径不存在就直接跳过,比在 zsh 里一行行 export PATH=... 干净很多。
status is-interactive 用来判断当前是不是交互式的 shell,只有交互式的时候才需要初始化 starship、zoxide 这些东西,用 fish 跑脚本的时候就可以省掉。type -q 则是检查命令存不存在,这样就算换到一台还没装 starship 的机器上,也不会报错。
变量和别名
fish 的变量用 set 设置,-g 是全局变量,-x 表示导出成环境变量,所以 zsh 里的 export FOO=bar 在 fish 里就是 set -gx FOO bar。
别名这块,fish 除了 alias,还有一个 abbr,也就是缩写。
status is-interactive; or return
abbr -a fzf fzfp
abbr -a cat bat
abbr -a c clear
alias ls 'eza --group-directories-first'
alias la 'eza -la --group-directories-first'
alias ll 'eza -l --group-directories-first'
alias l 'eza -1 --group-directories-first'
abbr 和 alias 不一样的地方在于,你输入 cat 再按空格,它会在命令行上直接展开成 bat。所以实际执行的命令、历史记录里留下的命令,都是展开之后的完整命令,别人看我的终端录屏也看得懂。我现在一般是想替换掉某个命令的时候用 abbr,像 ls 这种只是想加几个默认参数的,就还是用 alias。
把 zsh 的函数搬过来
fish 的语法和 bash、zsh 不兼容,所以原来的函数都得重写一遍。好在大多数函数都不长,改起来很快。拿我那个会判断当前 DNS 是不是 Surge 的 dog 函数来说,zsh 里是这样的。
DOG_SURGE_DNS="198.18.0.2"
DOG_DEFAULT_DNS="https://dns.alidns.com/dns-query"
function dog() {
if [[ "$@" == *@* ]]; then
doggo "$@"
else
current_dns=$(cat /etc/resolv.conf | grep '^nameserver' | awk '{print $2}' | head -n 1)
if [ "$current_dns" != "$DOG_SURGE_DNS" ]; then
doggo "$@"
else
doggo "$@" @"$DOG_DEFAULT_DNS"
fi
fi
}
换成 fish 之后,放在 functions/dog.fish 里。
function dog --description 'Run doggo with Surge-aware DNS fallback'
set -l dog_surge_dns 198.18.0.2
set -l dog_default_dns https://dns.alidns.com/dns-query
if string match -rq '@' -- "$argv"
doggo $argv
return
end
set -l current_dns (awk '/^nameserver/ { print $2; exit }' /etc/resolv.conf)
if test "$current_dns" != "$dog_surge_dns"
doggo $argv
else
doggo $argv @"$dog_default_dns"
end
end
几个比较明显的区别。参数是 $argv,而不是 $@。命令替换用的是括号 (...),而不是 $(...)。if 和 function 都以 end 结尾,没有 fi 和花括号。字符串处理有一个内置的 string 命令,string match -rq 就是用正则匹配,并且只返回结果不输出。另外 fish 的变量默认不会按空格拆分,所以也不需要像 zsh 那样到处加引号。
函数还可以带一个 --description,补全的时候会显示出来,过一段时间回头看,也知道这个函数是干什么的。
颜色
fish 默认的配色和我之前用的 zsh-syntax-highlighting 不太一样,用了这么多年的配色,突然换了还挺不习惯的。所以我在 conf.d/10-colors.fish 里把高亮的颜色调成了和 zsh 那边差不多的样子。
status is-interactive; or return
set -g fish_color_command green
set -g fish_color_keyword yellow
set -g fish_color_quote yellow
set -g fish_color_error red --bold
set -g fish_color_comment brblack --bold
set -g fish_color_autosuggestion brblack
set -g fish_color_valid_path --underline
命令是绿色,打错了是加粗的红色,自动建议是暗灰色,和以前一模一样,所以换过来之后基本感觉不到区别。
插件
虽然 fish 自带的已经够用了,不过有几个东西我还是想要。fish 的插件管理器我用的是 fisher,它很简单,插件列表就写在 fish_plugins 这个文件里,一行一个。
jorgebucaran/fisher
PatrickF1/fzf.fish
jhillyerd/plugin-git
xxxbrian/fzf-tab.fish
改完这个文件之后,运行 fisher update 就会按照列表安装或者删除插件。
fzf.fish
fzf.fish 给 fish 加上了一组 fzf 的快捷键,用来搜索历史记录、文件、git log、git status、进程和变量。我按自己的习惯重新绑了一下键。
if type -q fzf_configure_bindings
fzf_configure_bindings --history=\cr --directory=\e\cf --git_log=\e\cl --git_status=\e\cs --processes=\e\cp --variables=\cv
end
其中最常用的就是 Ctrl + R 搜索历史记录,和 zsh 里 fzf 的用法一样。
plugin-git
plugin-git 把 oh-my-zsh 的 git 插件里那一套缩写搬到了 fish 上,gst、gco、gcmsg 这些都还在。而且它们在 fish 里是用 abbr 实现的,所以输入之后会直接展开成完整的 git 命令。
fzf-tab.fish
在 zsh 里我很依赖 fzf-tab,按 Tab 的时候用 fzf 来选补全,还能预览文件和目录。在 fish 里,对应的插件是 fifc,用法差不多,也支持各种预览。
不过我现在用的终端是 Ghostty,fifc 在 Ghostty 里预览图片的时候有一些问题,比如图片残留在界面上、在图片和文本的预览之间切换时会闪。所以我 fork 了一份,改成了 xxxbrian/fzf-tab.fish,主要是让图片预览走 Ghostty 支持的 kitty 图形协议,切换预览的时候先把之前的图片清掉,媒体文件的预览也顺了很多。
它只需要设置一个编辑器,用来在补全的时候直接打开文件。我让它优先用 Helix,没有的话再退回 Neovim。
if not set -q fifc_editor
if type -q hx
set -gx fifc_editor hx
else if type -q nvim
set -gx fifc_editor nvim
end
end
POSIX 兼容
fish 最常被提到的缺点,就是它不兼容 POSIX,bash 和 zsh 的语法在 fish 里很多都不能直接用。比如网上复制来的一行 export FOO=bar && some_command,或者一段带 for ... do ... done 的循环,粘进 fish 里就会报错。
不过这件事对我的影响比想象中小很多。脚本文件本来就有自己的 shebang,写着 #!/bin/bash 的脚本,在 fish 里运行也还是交给 bash 去执行,完全不受影响。平时会碰到的,只有在命令行里临时敲的那些东西。而现在稍微复杂一点的管道命令和脚本,基本都是让 AI 去写,我只需要告诉它我用的是 fish,或者直接让它写成 bash 脚本再运行,所以问题也不大。实在需要的时候,临时开一个 bash 或者 zsh 也就是一条命令的事。
现在
现在 fish 已经是我的默认 shell 了。zsh 和 zinit 的配置我也没有删,还留着,万一哪天需要还能随时切回去,不过这几天用下来,好像也没有那个必要了。
回过头看,从 oh-my-zsh 到 zinit,我花了很多时间在让 zsh 变快、变好用上,而 fish 一开始就已经是这个样子了。配置文件也从原来那一堆 zi ice,变成了几个清清楚楚的小文件。