SLOPSHOPPER

file-browser

作業ディレクトリのファイルをペインに一覧し、文字のファイルは中で見て、ほかは既定のアプリで開く(/files)

newpanebandcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · file-browser
│ ┃ ファイル ✕ › fix the failing auth test and add an audit log call │ ┃ 📂 /work/app 読み直す エクスプローラーで開 │ ┃ 押す: 文字のファイルはここで見る・↗ ⏺ Read(src/auth.ts) │ ┃ は既定のアプリで開く・@ は入力欄へ @参照 ⎿ Read 6 lines │ ┃ ──────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ──────────── ⎿ Added 2 lines, removed 1 line │ ┃ フォルダではありません ⏺ Bash(bun test) │ ┃ ──────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ ──────────── │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /files │ │ ⟨Claude Code's own drawing⟩ ファイル一覧 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ファイル一覧
Pane · ファイル
📂 /work/app 読み直す エクスプローラーで開く 押す: 文字のファイルはここで見る・↗ は既定のアプリで開く・@ は入力欄へ @参照 ──────────────────────────────────────────────────────── フォルダではありません ────────────────────────────────────────────────────────
README

Claude Code mods: file-browser と session-memo

Windows 版の Claude Desktop(Code タブ)で使う、Claude Code の mod(plugin の中で動く hooks module)を2つ入れたマーケットプレイスです。 作った経緯と使ってみた感想は、ブログの記事に書きました: https://blog.sutachi.jp/posts/claude-code-mods-file-browser-session-memo/

mod何をするか入口
file-browser作業ディレクトリのファイルを木の形でペインに出します。フォルダは押すと開閉し、文字のファイル(.md・.txt・.csv・.json・.py など)はプレビューのペインで中身を見られます。それ以外のファイルは既定のアプリで開きます。各行の @ を押すと、入力欄に @<相対パス> が入ります(送信はしません)。/files・入力欄の上の帯の「ファイル一覧」
session-memoセッションごとにメモを1枚持てます。「メモを編集」を押すとメモ帳で開き、保存すると中身のある先頭の1行が入力欄の上の帯に出ます。/memo <文> と打てば、メモ帳を開かずに1行足せます。同じフォルダで前に使ったセッションのメモも開けます。/memo・帯の「メモを編集」

動く環境

  • Windows 版の Claude Desktop(Code タブ)を前提にしています。 外部のアプリは explorer.exe と notepad.exe で開きます。
  • mod を読み込める版の Claude Code が要ります(2.1.288 の型で書きました)。版は claude --version で確かめられます。これより古い版で動くかは確かめていません。
  • 組織の設定で allowManagedModsOnly が有効だと、ここから入れた mod は読み込まれません。GitHub から入れた plugin は、利用者自身の mod として扱われるためです。

入れ方

/plugin marketplace add AgemameSutachi/claude-code-mods
/plugin install file-browser@sutachi-mods
/plugin install session-memo@sutachi-mods

入れたら、Claude Code を開き直してください。

消し方

/plugin uninstall file-browser@sutachi-mods
/plugin uninstall session-memo@sutachi-mods
/plugin marketplace remove sutachi-mods

session-memo のメモの実体(~/.claude/session-memo/ の下の .txt)は、mod を消しても残ります。要らなければ、そのフォルダを手で消してください。

何をして、何をしないか

  • 使うイベントは session.start(コマンドの登録)・command.run・ui.render(帯とペイン)だけです。道具の実行や許可に関わるイベントは使いません。
  • file-browser はファイルを読むだけで、書き込みはしません。作業ディレクトリの外を指すもの(リンクの行き先を含む)は、開きも展開もしません(リンクの行そのものは一覧に出ます)。外部のアプリは、explorer.exe にパスを1つ渡す形でだけ起動します(シェルは通しません)。
  • session-memo がファイルを書くのは、メモの置き場(~/.claude/session-memo/<セッション id>.txt)の下だけです。作業ディレクトリには書きません。外部のアプリは、notepad.exe にメモのパスを1つ渡す形でだけ起動します。

分かっている限界

  • file-browser が1つのフォルダに出すのは 300 件まで、木全体では 600 行までです。プレビューで出すのは先頭の 90,000 字までです。
  • 空白を含む名前のファイルは @ 参照にできません(書き方を確かめていないため)。
  • session-memo のメモ帳は、セッションを閉じると一緒に閉じることがあります。書いたら保存してください。
  • session-memo は、環境変数 USERPROFILE も HOME も取れないとメモを置けません(帯に「家のフォルダが分からないのでメモを置けません」と出ます)。

謝辞

session-memo は、zenn で公開されている session-memo(yesodco さん・2026-10-06)の着想を借りました。コードは借りていません。

ライセンス

MIT(LICENSE)

Source 1 files
hooks/register.js 478 lines
1// ファイルブラウザー(Claude Code の mod)
2//
3// 【なぜ要るか】Desktop の Code タブでフォルダを押しても中身が開かない既知の不具合
4//   (anthropics/claude-code #63353・#65655・#60450)の回避。
5// 【何をするか】/files でペインを開き、セッションの作業ディレクトリの木を出す。フォルダは押すと展開・折りたたみ、
6//   文字のファイル(.md .txt .csv .json .py など)は押すと中身をプレビューのペインに出し、それ以外は既定のアプリで開く。
7//   各行の「外で開く」は既定のアプリ(フォルダならエクスプローラーでその場所)。
8//   種類ごとのアイコン・行ごとの区切り線・触れた行の色を付ける。
9//   Desktop の右の「ファイル」欄で開く口は mods の API に無い($.ui・$.session の一覧)ので、プレビューは自前のペイン。
10// 【入口】/files と、入力欄の上の帯の「ファイル一覧」ボタン(マウスだけで開ける)。
11// 【線】登録するのは session.start(コマンドの登録)・command.run・ui.render(ペインと帯)だけ。道具や許可に関わる
12//   イベントは登録しない。$.process.run は explorer.exe にパスを1つ渡す形だけ(シェルを通さない)。
13//   作業ディレクトリの外へは出ない($.fs.stat の realPath が作業ディレクトリの下にあるときだけ一覧・開く・読む)。
14//   読むのは $.fs.read だけで、書かない。$.prompt.fill は「@<相対パス> 」を mode 'insert' で入れる1か所だけ(送らない・2026-10-08)。
15// 【静的解析の決まり】$ は最上位の関数にだけ渡す・$ の名前空間を変数に入れない・on のイベント名は文字列で書く。
16
17const PANE = 'file-browser-list'
18const PREVIEW = 'file-browser-preview'
19// 1つのフォルダに出す数と、木全体の行数の上限(区切り線の分も含めてペインの文字数の上限 100,000 字に収める)
20const MAX_ENTRIES = 300
21const MAX_ROWS = 600
22// 区切り線の長さの上限(ペインの幅に合わせ、これより長くはしない)
23const RULE_MAX = 60
24// explorer.exe はすぐ戻る。居残る形でも画面が固まらないよう短く切る
25const OPEN_TIMEOUT_MS = 5000
26// プレビューで読む大きさの上限($.fs.read は 4 MiB まで)と、描く字数の上限(1つの木で描かれるのは先頭 100,000 字)
27const PREVIEW_MAX_BYTES = 4 * 1024 * 1024
28const PREVIEW_MAX_CHARS = 90000
29// 押すとプレビューで開く文字のファイル(小文字の拡張子)。実行されうるもの(.bat .ps1 .sh)も、ここでは読むだけ
30const TEXT_EXTENSIONS = new Set(
31  (
32    'md markdown txt log csv tsv json jsonl yaml yml toml ini cfg conf ' +
33    'py js mjs cjs ts tsx jsx html htm css scss xml svg sh bat cmd ps1 psm1 sql java c h cpp hpp cs go rs rb php r ' +
34    'gitignore gitattributes editorconfig'
35  ).split(/\s+/),
36)
37// Markdown として描く拡張子
38const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown'])
39// 種類ごとのアイコン(拡張子 → 絵文字)
40const ICONS = [
41  ['🖼️', 'png jpg jpeg gif bmp webp svg ico heic tif tiff'],
42  ['📊', 'ppt pptx key odp'],
43  ['📈', 'xls xlsx xlsm csv tsv ods'],
44  ['📕', 'pdf'],
45  ['📝', 'doc docx odt rtf md markdown txt log'],
46  ['💻', 'py js mjs cjs ts tsx jsx html htm css scss json jsonl yaml yml toml ini cfg conf xml sh bat cmd ps1 psm1 sql java c h cpp hpp cs go rs rb php r ipynb'],
47  ['🎵', 'mp3 wav m4a flac ogg aac'],
48  ['🎬', 'mp4 mov avi mkv webm wmv'],
49  ['🗜️', 'zip 7z rar tar gz tgz bz2 xz'],
50  ['⚙️', 'exe msi dll lnk'],
51]
52const ICON_OF = new Map()
53for (const [icon, list] of ICONS) for (const ext of list.split(' ')) ICON_OF.set(ext, icon)
54
55// 作業ディレクトリ(/files を打ったときに決める): shown は見せる綴り、real は解決した先(小文字・/ 区切り)
56let root = null
57// 展開しているフォルダ(作業ディレクトリからの相対・/ 区切り。'' は根)
58let expanded = new Set()
59// フォルダごとの一覧: { entries, more } か { error }
60let listings = new Map()
61// 読み込み中のフォルダ
62let loading = new Set()
63// 最後の知らせ(開いた・開けなかった)
64let notice = ''
65// プレビューしているファイル: { rel, text, truncated, markdown } か { rel, error }
66let preview = null
67
68// 比べるための形(/ 区切り・末尾の / なし・小文字。Windows は大小を区別しない)
69function comparable(path) {
70  return String(path).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
71}
72
73// real が根の下(根そのものを含む)か
74function isInside(rootReal, real) {
75  const target = comparable(real)
76  return target === rootReal || target.startsWith(rootReal + '/')
77}
78
79// 相対(/ 区切り)から、見せる綴りの絶対パスへ
80function absoluteOf(rel) {
81  if (!rel) return root.shown
82  return root.shown + root.sep + rel.split('/').join(root.sep)
83}
84
85function parentOf(rel) {
86  const index = rel.lastIndexOf('/')
87  return index < 0 ? '' : rel.slice(0, index)
88}
89
90function baseName(rel) {
91  return rel.slice(rel.lastIndexOf('/') + 1)
92}
93
94// 拡張子(小文字・点なし)。.gitignore のような点で始まる名前は名前そのもの
95function extensionOf(name) {
96  const lower = name.toLowerCase()
97  if (lower.startsWith('.') && lower.indexOf('.', 1) < 0) return lower.slice(1)
98  const index = lower.lastIndexOf('.')
99  return index <= 0 ? '' : lower.slice(index + 1)
100}
101
102function isTextFile(name) {
103  return TEXT_EXTENSIONS.has(extensionOf(name))
104}
105
106function iconOf(entry) {
107  if (entry.kind === 'dir') return '📁'
108  if (entry.isLink) return '🔗'
109  return ICON_OF.get(extensionOf(entry.name)) || '📄'
110}
111
112function messageOf(err) {
113  return String((err && err.message) || err)
114}
115
116// フォルダが先、その中は名前順(日本語の並び)
117function compareEntries(a, b) {
118  const rank = (entry) => (entry.kind === 'dir' ? 0 : entry.isLink ? 1 : 2)
119  const diff = rank(a) - rank(b)
120  return diff !== 0 ? diff : a.name.localeCompare(b.name, 'ja')
121}
122
123// 押せる部品の key(パスから作る短い印。日本語の名前でも英数字になる)
124function keyOf(prefix, rel) {
125  let hash = 5381
126  for (let i = 0; i < rel.length; i += 1) hash = ((hash * 33) ^ rel.charCodeAt(i)) >>> 0
127  return prefix + '-' + hash.toString(16) + '-' + rel.length
128}
129
130// 出す行の並び(展開しているフォルダだけ辿る)
131function visibleRows() {
132  const rows = []
133  const visit = (rel, depth) => {
134    const listing = listings.get(rel)
135    if (!listing) {
136      rows.push({ type: 'loading', rel, depth })
137      return
138    }
139    if (listing.error) {
140      rows.push({ type: 'error', rel, depth, text: listing.error })
141      return
142    }
143    for (const entry of listing.entries) {
144      if (rows.length >= MAX_ROWS) return
145      const child = rel ? rel + '/' + entry.name : entry.name
146      rows.push({ type: 'entry', rel: child, depth, entry })
147      if (entry.kind !== 'file' && expanded.has(child)) visit(child, depth + 1)
148    }
149    if (listing.more) rows.push({ type: 'more', rel, depth, count: listing.more })
150  }
151  visit('', 0)
152  return rows
153}
154
155// 作業ディレクトリを根に決め、根の一覧を読む
156async function initRoot($) {
157  const cwd = await $.session.cwd()
158  const stat = await $.fs.stat(cwd, { resolve: true })
159  const shown = cwd.replace(/[\\/]+$/, '') || cwd
160  root = { shown, real: comparable(stat.realPath || cwd), sep: cwd.includes('\\') ? '\\' : '/' }
161  expanded = new Set([''])
162  listings = new Map()
163  loading = new Set()
164  notice = ''
165  await loadDir($, '')
166}
167
168// 1つのフォルダを読む(作業ディレクトリの外を指すものは読まない)
169async function loadDir($, rel) {
170  const path = absoluteOf(rel)
171  try {
172    const stat = await $.fs.stat(path, { resolve: true })
173    if (stat.kind !== 'dir') {
174      listings.set(rel, { error: 'フォルダではありません' })
175      return
176    }
177    if (!stat.realPath || !isInside(root.real, stat.realPath)) {
178      listings.set(rel, { error: '作業ディレクトリの外を指すので開きません' })
179      return
180    }
181    const entries = await $.fs.list(path)
182    const sorted = [...entries].sort(compareEntries)
183    listings.set(rel, { entries: sorted.slice(0, MAX_ENTRIES), more: Math.max(0, sorted.length - MAX_ENTRIES) })
184  } catch (err) {
185    listings.set(rel, { error: '読めませんでした: ' + messageOf(err) })
186  }
187}
188
189// フォルダの展開・折りたたみ
190async function toggleDir($, rel) {
191  if (expanded.has(rel)) {
192    expanded.delete(rel)
193    $.ui.invalidate('ui.render')
194    return
195  }
196  expanded.add(rel)
197  loading.add(rel)
198  $.ui.invalidate('ui.render')
199  await loadDir($, rel)
200  loading.delete(rel)
201  $.ui.invalidate('ui.render')
202}
203
204// 展開しているフォルダを全部読み直す
205async function reloadAll($) {
206  for (const rel of [...expanded]) await loadDir($, rel)
207  notice = '読み直しました'
208  $.ui.invalidate('ui.render')
209}
210
211// 外で開く(ファイルなら既定のアプリ・フォルダならその場所)。$.process.run の形はこの1つだけ
212async function openInExplorer($, rel) {
213  const path = absoluteOf(rel)
214  try {
215    const stat = await $.fs.stat(path, { resolve: true })
216    if (!stat.realPath || !isInside(root.real, stat.realPath)) {
217      notice = '作業ディレクトリの外を指すので開きません: ' + (rel || root.shown)
218    } else if (!/^[a-z]:\//.test(comparable(path))) {
219      notice = 'Windows のパスではないので開けません: ' + path
220    } else if (path.includes(',')) {
221      // explorer.exe は , を区切りと読むので、名前に , があると別のものを開きうる
222      notice = '名前に , があるため開けません。上のフォルダの「外で開く」でエクスプローラーから開いてください: ' + rel
223    } else {
224      await $.process.run(['explorer.exe', path], { timeoutMs: OPEN_TIMEOUT_MS })
225      notice = '開きました: ' + (rel || root.shown)
226    }
227  } catch (err) {
228    notice = '開けませんでした: ' + (rel || root.shown) + '(' + messageOf(err) + ')'
229  }
230  $.ui.invalidate('ui.render')
231}
232
233// 文字のファイルをプレビューのペインに出す。大きい・文字でない・置けないときは外で開く
234async function openPreview($, rel) {
235  const path = absoluteOf(rel)
236  try {
237    const stat = await $.fs.stat(path, { resolve: true })
238    if (!stat.realPath || !isInside(root.real, stat.realPath)) {
239      notice = '作業ディレクトリの外を指すので開きません: ' + rel
240      $.ui.invalidate('ui.render')
241      return
242    }
243    if (stat.size > PREVIEW_MAX_BYTES) {
244      notice = '大きいので外で開きます: ' + rel
245      await openInExplorer($, rel)
246      return
247    }
248    const text = String(await $.fs.read(path))
249    if (text.includes('\u0000')) {
250      notice = '文字のファイルではないので外で開きます: ' + rel
251      await openInExplorer($, rel)
252      return
253    }
254    const truncated = text.length > PREVIEW_MAX_CHARS
255    preview = { rel, text: truncated ? text.slice(0, PREVIEW_MAX_CHARS) : text, truncated, markdown: MARKDOWN_EXTENSIONS.has(extensionOf(rel)) }
256  } catch (err) {
257    preview = { rel, error: '読めませんでした: ' + messageOf(err) }
258  }
259  const result = await $.ui.open({ id: PREVIEW, title: baseName(rel), focus: true, closeOnEscape: true })
260  if (!result.isPlaced) {
261    $.ui.toast('プレビューのペインを出せないので外で開きます')
262    await openInExplorer($, rel)
263    return
264  }
265  $.ui.invalidate('ui.render')
266}
267
268// ファイルを押したとき: 文字のファイルはプレビュー・それ以外は外で開く
269async function pressFile($, rel) {
270  if (isTextFile(baseName(rel))) {
271    await openPreview($, rel)
272    return
273  }
274  await openInExplorer($, rel)
275}
276
277// リンクは行き先を見て、フォルダなら展開・ファイルなら押したときと同じ
278async function pressLink($, rel) {
279  try {
280    const stat = await $.fs.stat(absoluteOf(rel), { resolve: true })
281    if (stat.kind === 'dir') {
282      await toggleDir($, rel)
283      return
284    }
285  } catch (err) {
286    notice = '行き先の無いリンクです: ' + rel
287    $.ui.invalidate('ui.render')
288    return
289  }
290  await pressFile($, rel)
291}
292
293// 入力欄へ @参照を差し込む(mode 'insert'=打ちかけの字は両側に残り、カーソルの位置に入る)。送りはしない。
294//   $.prompt.fill の型は index.d.ts の PromptFillArgs・PromptFillMode。
295//   空白を含む名前の @ の書き方は確かめていないので、入れずに知らせる(別のファイルを指すのを避ける)
296async function insertReference($, rel) {
297  if (!rel || /\s/.test(rel)) {
298    notice = '空白を含む名前は @参照にできません。「外で開く」か、名前を打ってください: ' + (rel || root.shown)
299    $.ui.invalidate('ui.render')
300    return
301  }
302  try {
303    const result = await $.prompt.fill({ text: '@' + rel + ' ', mode: 'insert' })
304    if (result && result.isFilled) {
305      notice = '入力欄に入れました: @' + rel
306    } else {
307      notice = '入力欄に入れられませんでした(入力欄が無いか、ダイアログが開いています): @' + rel
308      $.ui.toast('入力欄に入れられませんでした')
309    }
310  } catch (err) {
311    notice = '入力欄に入れられませんでした: ' + messageOf(err)
312    $.ui.toast('入力欄に入れられませんでした')
313  }
314  $.ui.invalidate('ui.render')
315}
316
317// ペインを開く(/files と帯のボタンの両方から)。置けなければ toast と transcript の文で知らせる
318async function openFiles($) {
319  try {
320    await initRoot($)
321  } catch (err) {
322    $.ui.toast('作業ディレクトリを読めませんでした')
323    return { text: '作業ディレクトリを読めませんでした: ' + messageOf(err) }
324  }
325  const result = await $.ui.open({ id: PANE, title: 'ファイル', focus: true, closeOnEscape: true })
326  if (!result.isPlaced) {
327    // 古い Desktop はペインを置かない(型の UiOpenResult)。置けなかったことを見える形で返す
328    $.ui.toast('ファイルの一覧のペインを出せませんでした')
329    return { text: 'ファイルの一覧のペインを出せませんでした: ' + result.reason }
330  }
331  return {}
332}
333
334// Markdown の囲み(中身にある最も長い ` の並びより1つ長くする)
335function fenced(text, language) {
336  let longest = 2
337  for (const run of text.match(/`+/g) || []) longest = Math.max(longest, run.length)
338  const fence = '`'.repeat(longest + 1)
339  return fence + language + '\n' + text + (text.endsWith('\n') ? '' : '\n') + fence
340}
341
342export function register(on) {
343  on('session.start', async ($, e, next) => {
344    await $.command.register({
345      name: 'files',
346      description: '作業ディレクトリのファイルをペインに一覧する(文字のファイルは中で見る・ほかは既定のアプリで開く)',
347      immediate: true,
348    })
349    return next(e)
350  })
351
352  on('command.run', { command: 'files' }, async ($) => openFiles($))
353
354  // 帯の「ファイル一覧」ボタン(コマンドを打たずにマウスだけで開けるように)。
355  //   mod が置ける入口は帯(AbovePrompt)とペインだけで、ツールバーやメニューの口は無い(reference の Render sites)。
356  //   session-memo の帯と1行に並ぶよう、後の mod の帯(next の結果)を左に伸ばし、ボタンを右端に置く
357  //   (session-memo が外側でも、こちらを同じ行の右に置く)。数字の hotkey は付けない(空の入力欄で数字を打つと押される)
358  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
359    if (e.props.hasSurvey) return next(e)
360    const theirs = await next(e)
361    const { Box, Button } = $.ui.resolve(e)
362    const button = Button({ key: 'files-open', label: 'ファイル一覧', plain: true, dimColor: true, onPress: () => openFiles($) })
363    const left = Box({ flexDirection: 'column', flexGrow: 1, children: theirs ? [theirs] : [] })
364    return Box({ flexDirection: 'row', columnGap: 2, children: [left, button] })
365  })
366
367  // プレビューのペイン
368  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
369    if (e.requestId !== PREVIEW) return next(e)
370    const { Box, Text, Button, Markdown } = $.ui.resolve(e)
371    if (!preview || !root) {
372      return Box({ flexDirection: 'column', children: [Text({ children: ['ファイル一覧からファイルを押してください'] })] })
373    }
374    const rel = preview.rel
375    const header = Box({
376      flexDirection: 'row',
377      columnGap: 2,
378      children: [
379        Text({ bold: true, wrap: 'truncate-start', children: [rel] }),
380        Button({ key: 'preview-ref', label: '@参照', plain: true, onPress: () => insertReference($, rel) }),
381        Button({ key: 'preview-outside', label: '外で開く', plain: true, onPress: () => openInExplorer($, rel) }),
382        Button({ key: 'preview-reload', label: '読み直す', plain: true, dimColor: true, onPress: () => openPreview($, rel) }),
383      ],
384    })
385    if (preview.error) {
386      return Box({ flexDirection: 'column', children: [header, Text({ color: 'red', children: [preview.error] })] })
387    }
388    const body = preview.markdown ? preview.text : fenced(preview.text, extensionOf(rel))
389    const tail = preview.truncated ? [Text({ dimColor: true, children: ['…長いので先頭だけ出しています。全部は「外で開く」で'] })] : []
390    return Box({ flexDirection: 'column', rowGap: 1, children: [header, Markdown({ key: 'preview-body', text: body }), ...tail] })
391  })
392
393  // 一覧のペイン
394  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
395    if (e.requestId !== PANE) return next(e)
396    const { Box, Text, Button } = $.ui.resolve(e)
397    if (!root) {
398      return Box({ flexDirection: 'column', children: [Text({ children: ['/files で開き直してください'] })] })
399    }
400    const width = Math.max(10, Math.min(RULE_MAX, Number(e.props && e.props.bodyColumns) || RULE_MAX))
401    const rule = () => Text({ dimColor: true, children: ['─'.repeat(width)] })
402    const header = Box({
403      flexDirection: 'row',
404      columnGap: 2,
405      children: [
406        Text({ bold: true, wrap: 'truncate-start', children: ['📂 ' + root.shown] }),
407        Button({ key: 'reload', label: '読み直す', plain: true, onPress: () => reloadAll($) }),
408        Button({ key: 'open-root', label: 'エクスプローラーで開く', plain: true, onPress: () => openInExplorer($, '') }),
409      ],
410    })
411    // 1行: 字下げ・アイコンつきの名前(押す)・右端に「外で開く」。触れた行は色を付け、行の下に区切り線
412    const line = (rel, depth, main, side) =>
413      Box({
414        key: keyOf('row', rel),
415        flexDirection: 'column',
416        children: [
417          Box({
418            key: keyOf('line', rel),
419            flexDirection: 'row',
420            columnGap: 1,
421            paddingX: 1,
422            hover: { backgroundColor: 'gray' },
423            children: [
424              Text({ children: ['   '.repeat(depth)] }),
425              Box({ flexGrow: 1, children: [main] }),
426              ...(side ? [side] : []),
427            ],
428          }),
429          rule(),
430        ],
431      })
432    const outside = (rel) => Button({ key: keyOf('out', rel), label: '外で開く', plain: true, dimColor: true, onPress: () => openInExplorer($, rel) })
433    // 右端: 「@」(入力欄へ @参照)と「外で開く」。リンクは行き先が作業ディレクトリの外のことがあるので「外で開く」だけ
434    const actions = (rel) =>
435      Box({
436        key: keyOf('acts', rel),
437        flexDirection: 'row',
438        columnGap: 1,
439        children: [Button({ key: keyOf('ref', rel), label: '@', plain: true, dimColor: true, onPress: () => insertReference($, rel) }), outside(rel)],
440      })
441    const lines = visibleRows().map((row) => {
442      if (row.type === 'loading') {
443        return line(row.rel + '#wait', row.depth, Text({ dimColor: true, children: ['読み込み中…'] }))
444      }
445      if (row.type === 'error') {
446        return line(row.rel + '#err', row.depth, Text({ color: 'red', children: [row.text] }))
447      }
448      if (row.type === 'more') {
449        return line(row.rel + '#more', row.depth, Text({ dimColor: true, children: ['…ほか ' + row.count + ' 件(エクスプローラーで見てください)'] }))
450      }
451      const { entry, rel } = row
452      if (entry.kind === 'dir') {
453        const mark = expanded.has(rel) ? '▾ ' : '▸ '
454        const busy = loading.has(rel) ? ' …' : ''
455        return line(
456          rel,
457          row.depth,
458          Button({ key: keyOf('dir', rel), label: mark + '📁 ' + entry.name + '/' + busy, plain: true, onPress: () => toggleDir($, rel) }),
459          actions(rel),
460        )
461      }
462      if (entry.isLink) {
463        return line(rel, row.depth, Button({ key: keyOf('link', rel), label: '🔗 ' + entry.name, plain: true, onPress: () => pressLink($, rel) }), outside(rel))
464      }
465      const hint = isTextFile(entry.name) ? '' : ' ↗'
466      return line(
467        rel,
468        row.depth,
469        Button({ key: keyOf('file', rel), label: iconOf(entry) + ' ' + entry.name + hint, plain: true, onPress: () => pressFile($, rel) }),
470        actions(rel),
471      )
472    })
473    const footer = notice ? [Text({ dimColor: true, wrap: 'truncate-end', children: [notice] })] : []
474    const legend = Text({ dimColor: true, children: ['押す: 文字のファイルはここで見る・↗ は既定のアプリで開く・@ は入力欄へ @参照'] })
475    return Box({ flexDirection: 'column', children: [header, legend, ...footer, rule(), ...lines] })
476  })
477}
478