【実装】herdr app の mod で Files パネルを拡張する — Markdown・HTML・PDF・画像をその場でプレビュー

この記事でわかること
- herdr app の mod の構成(main.js・mod.json、GPUI を組み立てる JavaScript)
- 同梱の mod は起動のたびに戻るので、複製してレイアウトから差し替えること
- TextView.markdown / html での表示と Preview / Source の切り替え
- image() が app のフォルダ基準でしか読めない問題と、隠しフォルダへのコピーでの回避
- PDF を macOS の PDFKit(JXA)でページ画像にして並べる方法
macOS(Apple Silicon)/ herdr app(2026年10月時点の版)。前の記事(CLI からの乗り換え)の続きです。画像と PDF の部分は macOS のコマンド(md5・stat -f・osascript・qlmanage)を使うので、他の OS ではそのまま動きません。
前の記事で herdr CLI から herdr app に乗り換えたあと、最初に不便だったのが Files パネルでした。Claude Code に書かせた README や設計メモを開くと、Markdown がソースのまま表示されるんですよね。PDF や画像は「バイナリなのでプレビューできません」で終わります。
herdr app は画面の部品を mod という JavaScript のモジュールで作っていて、自分でも書けます。そこで Files パネルを複製して、プレビューを足しました。
この記事でやること
- mod の仕組みを確かめる
- 同梱の Files パネルを複製して自分のレイアウトで使う
- Markdown・HTML のプレビューと Preview / Source の切り替えを付ける
- 画像と PDF をパネル内で表示する
はじめに
エージェントに作業を任せていると、生成物を確かめるたびにエディタやブラウザに移るのが地味に面倒でした。ターミナルの隣にある Files パネルでそのまま読めれば、アプリを行き来しなくて済みます。
対象読者
- herdr app を使っていて、画面を自分好みにしたい人
- JavaScript で UI を組んだことがある人(React など)
mod の仕組み
mod は ~/.config/herdr-app/mods/<name>/ に置くフォルダです。最低限必要なのは2つでした。
mods/<name>/
├── main.js # mod 本体(View を継承したクラスを default export)
└── mod.json # 最初にどこに出すか {"place": "right" | "none" ...}描画は HTML ではありません。gpui-kit というライブラリで GPUI(Rust の GPU 描画 UI)の要素を組み立て、それを Rust 側が描きます。DOM は無くて、div().px(8).text_color(...) のようにメソッドをつなげて書きます。
import { View, div } from "gpui-kit";
import { v_flex } from "gpui-base";
import { theme } from "herdr";
export default class Hello extends View {
init(_props, cx) { this.count = 0; }
render() {
const t = theme();
return v_flex().size_full().p(12).bg(t.surface)
.child(div().id("btn").on_click((_e, cx) => { this.count++; cx.notify(); })
.child(`clicked ${this.count}`));
}
}React を触ったことがあれば読める形ですが、状態を変えたら自分で cx.notify() を呼ぶところだけ違います。他にも決まりがいくつかあって、app が書き出す AGENTS.md にまとまっています。
| 決まり | 理由 |
|---|---|
setTimeout / setInterval は使わず cx.timer |
mod の再読み込みで止められるように |
受け取った cx を持ち越さない |
コールバックごとに新しい cx が渡る |
色は theme() から取る |
ユーザーのテーマに追従させる |
重い処理は exec か fetch |
UI スレッドを止めない |
ファイルを保存するとその場で再読み込みされます。エラーを確かめるときは herdr-app check <mod のフォルダ> が便利で、一度描画してみて問題があればエラーを返してくれます。
同梱の mod は複製して使う
最初は同梱の mods/files/main.js を直接編集しようとしたんですが、AGENTS.md にこう書かれていました。
herdr 自身のレイアウトと mod は読み取り専用で、起動のたびに丸ごと置き換えられる。編集しても次の起動で
<name>-editedに退避され、元に戻る。
なので、複製した mod を作って、レイアウトの参照先をそちらに切り替えます。公式には mod.clone と layout.clone というコマンドがあります。
herdr-app do mod.clone '{"name":"files","as":"my-files"}'
herdr-app do layout.clone '{"name":"main","as":"my-layout"}'複製したレイアウトは元の mod を参照したままなので、layouts/my-layout.json の中の "mod": "files" を "my-files" に書き換えます。レイアウトは入れ子の JSON なので、全体をたどって置き換えるようにしました。
import json
d = json.load(open("layouts/my-layout.json"))
def walk(x):
if isinstance(x, dict):
c = x.get("content")
if isinstance(c, dict) and c.get("mod") == "files":
c["mod"] = "my-files"
for v in x.values(): walk(v)
elif isinstance(x, list):
for v in x: walk(v)
walk(d)
json.dump(d, open("layouts/my-layout.json", "w"), indent=2, ensure_ascii=False)layout.clone した時点で my-layout に切り替わっているので、書き換えれば自分の Files パネルが使われます(別のレイアウトにしていたら Ctrl+Shift+L で順に切り替えられます)。ただし複製は自分の管理になるので、同梱の Files が更新されても反映されません。
Markdown と HTML のプレビュー
同梱の Files は、ファイルをクリックすると readTextFile で読んで、読み取り専用のエディタ(Editor)で表示する作りでした。ここに「プレビューできる種類か」の判定を足します。
function kindOf(path) {
const ext = (path?.split(".").pop() ?? "").toLowerCase();
if (["md", "mdx", "markdown"].includes(ext)) return "md";
if (["html", "htm", "xhtml"].includes(ext)) return "html";
if (["png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff", "ico", "svg"].includes(ext)) return "image";
if (ext === "pdf") return "pdf";
return null;
}Markdown と HTML は gpui-base の TextView で表示できます。型定義(gpui-kit.d.ts)を読むと、リンククリックを拾う on_link_click もありました。
const view = this.kind === "html"
? TextView.html(`html-${this.file}`, this.text)
: TextView.markdown(`md-${this.file}`, this.text);
return v_flex().id("md-preview").flex_1().min_h(0).overflow_y_scrollbar().px(18).py(14)
.child(view.selectable(true).on_link_click((url, cx) => this.onLink(url, cx)));リンク先が相対パスの .md や画像なら同じパネルで開き、URL などそれ以外は macOS の open で標準アプリに渡しています。
ヘッダーには Preview / Source の切り替えを置きました。Source 側は同梱の Files と同じ Editor なので、行番号とシンタックスハイライト付きでソースを読めます。
modeSwitch(t) {
const seg = (mode, label) => div().id(`md-mode-${mode}`).px(8).h(22).flex().items_center()
.rounded(5).cursor_pointer().text_size(11.5)
.bg(this.mdMode === mode ? t.selected : t.background)
.text_color(this.mdMode === mode ? t.foreground : t.muted)
.on_click((_e, cx) => { this.mdMode = mode; cx.notify(); }).child(label);
return h_flex().gap(2).p(2).rounded(7).border_1().border_color(t.border)
.child(seg("preview", "Preview")).child(seg("source", "Source"));
}HTML には制約があって、型定義に「CSS in HTML is not supported」と書かれています。見出しやリストは出ますが、レイアウトは崩れます。そのためヘッダーに「Open ↗」ボタンも付けて、ちゃんと見たいときはブラウザで開けるようにしました。
画像は app のフォルダ経由で読む
画像は image(path) で出せるのですが、型定義によると path は「app 自身のフォルダ」を基準に解決されます。他の mod も image("mods/agents/...") のように ~/.config/herdr-app からの相対パスで書いています。プロジェクトの画像をそのままは指定できないので、一度 mod のフォルダにコピーしてから読みます。
mod="$HOME/.config/herdr-app/mods/my-files"; c="$mod/.cache"; mkdir -p "$c"
find "$c" -mindepth 1 -maxdepth 1 -mtime +2 -exec rm -rf {} + 2>/dev/null || true
id="$(md5 -q -s "$src")-$(stat -f %m "$src")"
ext="$(printf %s "${src##*.}" | tr A-Z a-z)"; f="img-$id.$ext"
[ -e "$c/$f" ] || cp "$src" "$c/$f"工夫したのは2点です。
- ファイル名にパスのハッシュと更新時刻を入れる。画像を更新すると別の名前になるので、古い画像が表示され続けません
- コピー先を
.cacheという隠しフォルダにする。mod のフォルダにファイルを書くと再読み込みが走りますが、隠しファイルは対象外です
mod からは exec("sh", ["-c", SCRIPT, "asset", absPath, kind]) でこのスクリプトを呼び、返ってきた相対パスを image() に渡します。exec はユーザーの環境(PATH など)で動き、制限時間は30秒です。
PDF は PDFKit でページ画像にする
PDF を表示する部品は gpui-kit にも見当たりませんでした。そこで、ページを PNG にして画像として縦に並べています。macOS には PDFKit があるので、追加のインストール無しに JXA(JavaScript for Automation)から呼べます。
// osascript -l JavaScript pdf2png.jxa <pdf> <outdir> [maxPages] [width]
ObjC.import('AppKit');
ObjC.import('Quartz');
function run(argv) {
const src = argv[0], out = argv[1], max = Number(argv[2]) || 40, W = Number(argv[3]) || 1400;
const doc = $.PDFDocument.alloc.initWithURL($.NSURL.fileURLWithPath(src));
if (!doc || doc.isNil()) throw new Error('Cannot open PDF');
const total = Number(doc.pageCount), n = Math.min(total, max), images = [];
for (let i = 0; i < n; i++) {
const page = doc.pageAtIndex(i);
const b = page.boundsForBox(0); // kPDFDisplayBoxMediaBox
const rot = Number(page.rotation) % 180 !== 0;
const w0 = rot ? b.size.height : b.size.width, h0 = rot ? b.size.width : b.size.height;
const H = Math.round(W * h0 / w0);
const img = page.thumbnailOfSizeForBox($.NSMakeSize(W, H), 0);
const rep = $.NSBitmapImageRep.imageRepWithData(img.TIFFRepresentation);
const png = rep.representationUsingTypeProperties(4, $({})); // PNG
const name = 'p' + String(i + 1).padStart(3, '0') + '.png';
png.writeToFileAtomically(out + '/' + name, true);
images.push({ name: name, w: W, h: H });
}
return JSON.stringify({ pages: total, images: images });
}各ページの幅と高さを JSON で返しているのは、mod 側で縦横比を保って並べるためです。パネルの幅は on_size_change で受け取れるので、そこから各ページの高さを計算します。
const w = Math.max(120, (this.width ?? 368) - 36);
return v_flex().id("pdf-preview").flex_1().min_h(0).overflow_y_scrollbar().p(12).gap(10)
.children(index.images.map(p => image(dir + p.name).w(w).h(Math.round(w * p.h / p.w)).flex_none()));JXA が失敗したときは、Quick Look の qlmanage -t で 1ページ目のサムネイルだけでも出すようにしています。ページ数が多い PDF は exec の30秒制限にかかるので、最初の30ページ・幅1200px までにしました。
ターミナルからも開く
Files パネルとは別に、ペインとして開く Markdown プレビュー(md-preview)も作りました。mod は pane.split でターミナルの隣にペインとして開けるので、zsh の関数にしておくと Claude Code が書いたファイルをすぐ開けます。
mdp() {
local f="${1:-README.md}"
[[ -f "$f" ]] || { echo "mdp: $f not found" >&2; return 1; }
f="${f:A}"
herdr-app do pane.split "{\"direction\":\"right\",\"mod\":\"md-preview\",\"props\":{\"file\":\"$f\"}}"
}ターミナルの隣に README.md を開くと、こんな感じで並びます。

ペイン側の mod は props().file でファイルパスを受け取り、1.5秒ごとに読み直して中身が変わっていたら描き直します。watchFolder という変更通知の API もありますが、フォルダを再帰的に監視するので、node_modules があるリポジトリでは通知が多すぎます。型定義(herdr.d.ts)にも「表示しているものだけを監視するように」と書かれています。
Tips
- 型定義(
gpui-kit.d.ts・herdr.d.ts)は app が起動時に~/.config/herdr-app/に書き出します(gpui-kit.d.tsは各 mod のフォルダにも置かれます)。メソッド名に迷ったら grep するのが早いです - 同梱 mod(files・tabs・spaces など)はそのまま書き方の見本になります
- どの mod でエラーが出ているかは
herdr-app statusで確かめられます - 別のマシンにつないだ space のファイルは、この作りだと画像と PDF を表示できません。コピーも変換も手元の Mac でやっているためです
まとめ
- herdr app の mod は
main.jsとmod.jsonのフォルダで、GPUI の要素を JavaScript で組み立てる - 同梱の mod は起動のたびに戻るので、複製してレイアウトの参照先を差し替える
- Markdown と HTML は
TextViewで表示できる。HTML は CSS 非対応なので「Open ↗」を併用 image()は app のフォルダ基準でしか読めないので、隠しフォルダにコピーしてから渡す- PDF は PDFKit(JXA)でページ画像にして並べる
次の記事では、同じ複製のやり方でサイドバーやタブバーを作り替えた話を書きます。


