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

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・帯の「メモを編集」 |
explorer.exe と notepad.exe で開きます。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(帯とペイン)だけです。道具の実行や許可に関わるイベントは使いません。explorer.exe にパスを1つ渡す形でだけ起動します(シェルは通しません)。~/.claude/session-memo/<セッション id>.txt)の下だけです。作業ディレクトリには書きません。外部のアプリは、notepad.exe にメモのパスを1つ渡す形でだけ起動します。@ 参照にできません(書き方を確かめていないため)。USERPROFILE も HOME も取れないとメモを置けません(帯に「家のフォルダが分からないのでメモを置けません」と出ます)。session-memo は、zenn で公開されている session-memo(yesodco さん・2026-10-06)の着想を借りました。コードは借りていません。
MIT(LICENSE)
hooks/register.js 478 lines1// ファイルブラウザー(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