fz-browseという、fzfライクなインタラクティブな検索ツールを書きました。

fz-browse自体は検索エンジンを持っているわけではなく、既存のripgrepripgrep-allなどのツールと組み合わせて、インタラクティブな検索体験を自分で組み立てるツールです。

コマンドを組みあわせたインタラクティブな検索(絞り込み)はpercolpecofzfなどが有名ですが、これらは検索の結果や操作もターミナル上で行います。

ターミナルは、書籍や画像などのコンテンツを検索した結果を表示してもあまり楽しくない場合があります。 fz-browserでは既存のコマンドラインツールを組み合わせて検索するのは同じですが、この検索結果の表示や操作をウェブブラウザ上で行います。

ブラウザで表示できるので、検索結果がPDFやEpubの書籍だったらそのままビューアで開いたり、画像だったらそのまま表示できるようになっていて、テキスト以外の結果にも対応しています。

ripgrep-allを使って、PDFやEpubを検索している様子

ExifToolを使って、画像のExifに含まれるコメントや日付を検索している様子

使い方

fz-browseはNode.jsで動いているツールなので、npmでインストールします。

npm install --global fz-browse

最初に書いていたように、fz-browse自体は検索エンジンではなく、既存のコマンドを組み合わせます。

テキストを検索する

ripgrepで検索する例です。

次のコマンドを実行すると、検索欄に入力するたびに--runフラグに指定されているrgコマンドで検索した結果が表示されます。 また、検索したファイルのリンクを開くと、--previewフラグで指定されているrgコマンドで検索した結果(前後5行)が詳細表示されます。

fz-browse --run $'rg --ignore-case {input} --json | jq \'if .type == "begin" or .type == "match" then . else empty end | [.data.path.text, .data.lines.text] | @tsv\' -r' --preview "rg --context 5 {input} {target}" --open

電子書籍を検索する

ripgrep-allを使ってPDFやepubなどの電子書籍を検索する例です。

次のコマンドを実行すると、検索欄に入力するたびに--runフラグに指定されているrgaコマンドで検索した結果が表示されます。 また、検索したファイルのリンクを開くと、PDFとEpubはビルトインのビューアによってそのままブラウザで開いて読めるようになっています。 それ以外のファイルは、--previewフラグで指定されているrgaコマンドで検索した結果(前後5行)が詳細表示されます。

fz-browse --run $'rga --ignore-case {input} --json | jq \'if .type == "begin" or .type == "match" then . else empty end | [.data.path.text, .data.lines.text] | @tsv\' -r' --preview "rga --context 5 {input} {target}" --open

画像ファイルのEXIFを検索する

画像ファイルのEXIFを元に検索する例です。

次のコマンドを実行すると、検索欄に入力するたびに--runフラグに指定されているコマンドで、画像データのEXIFに対してgrepして画像を絞り込めます。 exiftool -q -m -p \'$Directory/$Filename $DateTimeOriginal $Comment\' で画像ファイルのEXIFをテキストとして取り出してgrepでそれを絞り込む仕組みです。

また、検索したファイルのリンクを開くと、jpgやpngなどの画像はビルトインのビューアによってそのままブラウザで表示できます。 それ以外のファイルは、--previewフラグで指定されているechoでファイル名が表示されます。

fz-browse --run $'find -E . -iregex ".*\.(jpg|gif|png|jpeg)$" -print0 | xargs -0 exiftool -q -m -p \'$Directory/$Filename  $DateTimeOriginal  $Comment\' | grep {input} | awk \'{print $1}\'' --preview "echo {target}" --open

特定のディレクトリを対象にしたい場合は、--cwdで実行ディレクトリを指定できます。

基本的には、--runで指定したコマンドが結果を<filePath>\t<content>のTSVとして返すようにして、それをブラウザで逐次表示できるような形になっています。

その他の使い方は、READMEを参照してください。

また、いい使い方があったら、Wikiに追加してみてください!

仕組み

fz-browseは、ローカルサーバを立ち上げて、ブラウザから立ち上げたローカルサーバにアクセスして利用します。 検索窓にテキストを入れると、サーバにリクエストが発生して--runで指定したコマンドを実行した結果をNode.js Streamでレスポンスとして返し、レスポンスをWeb Streams APIストリーミングしながら取得して、表示しています。

Architecture

これによって、コマンドの実行中でも検索結果が徐々に表示されていくような、ターミナルでの検索と似た体験ができるようになっています。

ストリームで取得したデータでStateを頻繁に更新すると、入力中に検索結果の要素が頻繁に更新されるため、表示が固まってしまいます。 これに対してはReact 18で追加されたuseTransitionを使って、検索結果の表示の優先度を落とすことで対応しています。

ビルドインのプレビュー表示として、PDF.jsを使ったPDFビューア、Epub.jsを使ったepubビューアを実装してあります。

また、構想としてはこのプレビュー自体のカスタマイズに対応できるようにある程度整理しています。(まだユースケースがわからないので切り出しはいないです) 興味がある人はいじってみてください。

モチベーション

モチベーションとしては、ローカルの書籍を検索したくて、ちょうど良いツールが見つからなくて作り始めました。 ripgrep-allで検索はできるのですが、ターミナルにテキストで書籍の中身が出てきてもあまり楽しくないという感覚がありました。

はてなブックマーク検索PWAazu/mytweetsなどを書いた経験的に、検索はブラウザでインタラクティブにできてすぐ結果が出てくると楽しい感じがしました。

最初は、検索エンジン自体もRustで書こうとしていましたが、ripgrep-allとかripgrepとかugrepとか既に色々あるので、既存の検索コマンドのフロントエンドがあればいいのかなと考えました。

fzfのGUI版みたいのがあればいいのかなーと思って、PoCを書いてみたら意外と書けそうだったのでfz-browseを作りました。

電子書籍の検索だけに絞ってもいいかなーとは思ってたんですが、色々デザイン考えていたら、ある程度汎用的な感じにできそうだったので、汎用的な仕組みなりました。 汎用的にできそうと感じたのは、コマンドの実装結果をTSVでブラウザに返すというアイデアを思いついたところあたりだった気がします。 (コマンドでJSONみたいな構造を作るのはなんか違うし、ブラウザではストリーム表示したいので行で表示できるものを探しててTSVになったという感じ。jqがサポートしてたのも大きい)

Node.jsのツールになった理由はあんまりありませんが、ViteのSSRサーバを書いてみたかっただけです。 開発体験的にHot Reloadとかできるので悪くはないです。サイズとかは気になるけど、結局ブラウザを扱うツールなのでJavaScriptでいいかなと。

おわりに

fz-browseという、コマンドを組み合わせて自分好みの検索ツールを作るツールを書きました。 検索自体はブラウザで行えるので、テキスト以外のものの結果も扱いやすかったり、普通に検索して表示できます。

まだ作ったばかりのツールなので、面白いアイデアがあったらIssueください。