🙆‍♀️

多段fzf: fzfの候補リストをfzfで更新する

2021/08/01に公開

fzf でファイル名や git の hash の補完をすると、かなり効率的に候補を絞り込み・特定することができます。公式の ADVANCED.md でも面白い使い方が紹介されていると思いますが、今回はこれをもう一段進化させることを目標とします。

https://github.com/junegunn/fzf/blob/master/ADVANCED.md

fzf を活用していると、たまに fzf の中から fzf を呼び出したくなることがあります。たとえば、

  • git の cherry-pick のために、current branch ではなく別のブランチの hash を得るために、branch 名を fzf で選んで hash リストを更新したい。
  • ファイルを補完する際に、別のディレクトリ(親ディレクトリの場合が多いが)にコンテキストを変更して候補リストを更新したい。移動先は fzf で選びたい。

などなど。

そもそも fzf の中から fzf を呼び出す、ということ自体が少しトリックを使わないと難しかったりするのですが、順番に課題を解決していきます。

デモ

まずは最終形がどうなるのかを簡単に確認してみましょう。ここでは私の普段の開発環境を Docker イメージにパッケージングした anyakichi/myenv という環境を使います。動画のとおりに操作してもらえば、みなさんの手元でもまったく同じことが再現できるはずです。

まずは下準備として、適当なリポジトリ(今回は Linux カーネルのリポジトリにします)を clone しておきます。動画はこのリポジトリ内にいるところからスタートします。ちょっと長いですが、動画内では以下のことをしています。

  • (下準備)
    • myenv に入る
    • tmux を起動する
  • (git 編)
    • cherry-pick の hash 値補完のため、fzf で git log を表示する (C-h)
    • 最新のコミットを preview window より広い less (bat) で表示してみる (C-t)
    • less を終了する
    • 別のコミットで上記をもう一回
    • 表示中の git log のブランチを変更する fzf を起動する (C-x)
    • ブランチ名がかぶってしまっているので、preview window を消してみる(C-/
    • 5.7 というクエリーを入力し、linux-5.7.y ブランチにスイッチする
    • 先頭のコミットを less で表示してみる
    • 適当にコミットを選ぶ。checy-pick の都合上古い方から順に (S-Tab)
  • (fd/find 編)
    • vi で開くファイルを fzf のファイル補完で検索する (C-/)
    • printk というキーワードでファイルを探す
    • 検索ディレクトリを変更するための fzf を起動する (C-d)
    • printk というキーワードを入力し、printk ディレクトリに移動する
    • kernel/printk/printk.c を選ぶ
  • (rg 編)
    • cd driversdrivers ディレクトリに移動する
    • fzf で rg を起動する (C-g C-g)
    • ioremap_nocache という文字列を探してみる
    • 見つからないので親ディレクトリに移動する (C-s)
    • 調子に乗ってもう一回親ディレクトリに移動する (C-s)
    • 失敗したのでディレクトリ移動のための fzf を起動する (C-d)
    • query を .. に変更して、これで確定させる (C-q)
    • 候補を指定してファイルを開く

https://www.youtube.com/watch?v=KiDzX0guDpI

fzf-utils

上記の fzf 操作を行うためのファイル一式は、fzf-utils としてプラグイン化されているので、こちらをご利用ください(現状は zsh のみサポートしています)。

https://github.com/anyakichi/fzf-utils

内部動作について

以下は多段 fzf などを行うための技術的な解説です。興味のない方はこれ以降は読まなくても OK です。お疲れ様でした。

注意力の高い方は気づいたかもしれませんが、fzf から less を呼び出すとき、もしくは fzf から fzf を呼び出すタイミングで tmux の window が開いています。これは fzf が動いているのと同じ端末 (tty) では別の tty アプリケーションを開けないためです。

stdout はどこを向いている

less など端末に描画する系のアプリケーションは、stdout が tty に向いているときにしか意図通り動作しません。

これは stdout が tty なので OK。

$ who | less

これは stdout が tty ではなくなるので NG。

$ less /etc/group | grep root

これも同様に stdout が tty ではなくなるので NG。

$ echo $(less /etc/group)

NG ケースでは、less が cat と同じような動作になってしまうと思います。これが通常 fzf から less や fzf を再帰的に呼び出せない理由です(stdout が tty かどうかは、test コマンドを使って test -t 1 で確かめることができます)。

less を tmux window に「逃がす」

というわけで、fzf-utils では less や fzf を別の tmux window で開くことでアプリケーションに「tty を提供」しています。まずは tmux new-window を直接使ってみましょう。これを使うと先程 NG だったパターンで less が開くと思います。

$ tmux new-window less /etc/group | grep root
$ echo $(tmux new-window less /etc/group)

さて、less は開いていると思いますが、less が開いた状態で元の window に戻ってみると、実行が終了してしまっていると思います(grep rootecho まで終了している)。これでいい場合はこれでいいのですが、less が終了するまで実行を待ってほしい場合には、tmux の wait-for という機能を使うと実現できます。

fzf-utils には wait-for まで行ってくれる tview というラッパースクリプトが含まれているので、これを使ってみます。

$ tview less /etc/group | grep root
$ echo $(tview less /etc/group)

今度は less を終了するまでコマンドの実行が待たされていると思います。

ファイルでステートの共有

しかし less の場合はこれで良いのですが、fzf を実行してその出力を使いたい場合はどうすれば良いでしょうか。fzf は tmux の tty に接続されているわけなので、出力は tmux の window に出力されて終わってしまいます(すぐ閉じてしまうので、出力を見ることすら難しいですが)。

結局 fzf から呼んだ fzf の値を親の fzf がもらいたいと思うと、ファイルや名前付きパイプなど外部的なパスで得る必要があります。fzf-utils では mktemp で作った一時ファイルを親子で共有する形式にしています。

この共有ファイルは FZF_STATE という環境変数に入っていることとし、セットされていなければファイルを作って FZF_STATE を埋める、セットされていればこれを使うという感じですね。これは _fzf-state というファイルで実現されており、これを source しておけば共有 state が使えるという仕組みです。

ちなみに tmux new-window をする際にこの環境変数が新しい window に渡されなければいけないので、tview では継承させる環境変数の名前も指定できるようになっています。

統合

これで fzf から fzf を呼んでその結果を受け取るという一連の動きを実現する仕組みができました。あとはまあ適当にゴリゴリ作っていけばそれっぽいものが完成します。

fzf-utils にはこの他にも動的置換可能なセッティングがされた様々な機能が含まれているので、fzf-utils の README や C-g C-h などを実行したあとに 1 行目に表示されているヘルプを参照してください。

その他もろもろ

  • 実際のところ fzf は stdout が tty じゃなくても UI が開くのですが、多段にするためには tmux に逃がす必要があるようです。
  • 動画と同じ動きにするには FZF_DEFAULT_OPTS なども設定する必要があります。
  • 動画にテロップが入っていたほうがわかりやすいと思うのですが、面倒くさいので諦めました…。

Discussion