告別 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,變成幾個清清楚楚的小檔案。