แผงด้านข้างแสดงไฟล์ที่ Claude อ่าน แก้ไข สร้าง หรือลบ จัดกลุ่มตามโฟลเดอร์ พร้อมไอคอนไทย

แผงด้านข้างที่แสดงทุกไฟล์ที่ Claude อ่าน แก้ไข สร้าง หรือลบ จัดกลุ่มตามโฟลเดอร์ บอกจำนวนครั้งและเวลาล่าสุด มีไอคอนตามสถานะ (👁️ อ่าน ✏️ แก้ไข ✨ สร้าง 🗑️ ลบ) และตามชนิดไฟล์
| คำสั่ง | ทำอะไร |
|---|---|
/files | เปิดแผง |
/files list | แสดงเป็นข้อความ |
/files clear | ล้างรายการ |
/plugin install filetree-th --marketplace tksoft11/claude-code-mods-th
ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)
เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:
deny และ isError หลัง next)$.fs.exists$.ui.open และวาดด้วย ui.render บน Pane$.ui.invalidateการตรวจ rm ใน Bash เป็นแบบง่าย อ่านแค่ชื่อไฟล์หลังคำสั่ง rm
hooks/register.tsx 183 lines1/**
2 * ============================================================================
3 * MOD 04: Filetree TH - แผงดูไฟล์ที่ Claude แตะ (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: https://github.com/data-goblin/claude-filetree
6 *
7 * หน้าที่ของ Mod นี้:
8 * 1. ดักทุก tool ที่แตะไฟล์ (Read, Edit, Write, Bash rm)
9 * 2. จำว่าแต่ละไฟล์ถูกทำอะไร กี่ครั้ง ล่าสุดเมื่อไหร่
10 * 3. วาดแผง (Pane) ด้านข้าง จัดกลุ่มตามโฟลเดอร์ + ไอคอนตามสถานะและชนิดไฟล์
11 * 4. คำสั่ง /files เปิดแผง, /files list แสดงเป็นข้อความ, /files clear ล้างรายการ
12 */
13
14import type { EngineInterface, Register } from 'claude-code'
15
16type FileStatus = 'read' | 'edited' | 'created' | 'deleted'
17
18type FileInfo = {
19 path: string // path เทียบกับโฟลเดอร์โปรเจกต์
20 status: FileStatus // สถานะล่าสุด
21 count: number // ถูกแตะกี่ครั้ง
22 lastTouched: number // เวลาล่าสุด (ms)
23}
24
25const PANE = 'filetree-th' // id ของแผง ใช้ทั้งตอนเปิดและตอนวาด
26const MAX_FILES = 300
27
28let projectRoot = '' // โฟลเดอร์โปรเจกต์ (ได้จาก session.start)
29let files: FileInfo[] = [] // ไฟล์ทั้งหมดที่ถูกแตะ (เริ่มใหม่เมื่อ reload mod)
30
31// ไอคอนตามสถานะ
32const STATUS_ICON: Record<FileStatus, string> = { read: '👁️', edited: '✏️', created: '✨', deleted: '🗑️' }
33const STATUS_TH: Record<FileStatus, string> = { read: 'อ่าน', edited: 'แก้ไข', created: 'สร้าง', deleted: 'ลบ' }
34
35// ไอคอนตามนามสกุลไฟล์
36function fileIcon(path: string): string {
37 const name = path.toLowerCase()
38 if (/\.(tsx|jsx)$/.test(name)) return '⚛️'
39 if (/\.ts$/.test(name)) return '📘'
40 if (/\.(js|mjs|cjs)$/.test(name)) return '📒'
41 if (/\.py$/.test(name)) return '🐍'
42 if (/(^|\/)\.env/.test(name)) return '🔐'
43 if (/\.(css|scss|svg|png|jpg)$/.test(name)) return '🎨'
44 if (/\.(md|txt)$/.test(name)) return '📝'
45 if (/\.(json|ya?ml|toml)$/.test(name)) return '📋'
46 return '📄'
47}
48
49// แปลง path เต็มเป็น path สั้นเทียบกับโปรเจกต์ (รองรับทั้ง / และ \ ของ Windows)
50function shortPath(path: string): string {
51 const p = path.replace(/\\/g, '/')
52 const root = projectRoot.replace(/\\/g, '/').replace(/\/$/, '')
53 return root && p.toLowerCase().startsWith(root.toLowerCase() + '/') ? p.slice(root.length + 1) : p
54}
55
56function timeAgo(ms: number): string {
57 const sec = Math.floor(ms / 1000)
58 if (sec < 60) return `${sec}วิที่แล้ว`
59 if (sec < 3600) return `${Math.floor(sec / 60)}นาทีที่แล้ว`
60 return `${Math.floor(sec / 3600)}ชม.ที่แล้ว`
61}
62
63// ==========================================================================
64// บทที่ 1: จดไฟล์ - หาไฟล์เดิมแล้วอัปเดต หรือเพิ่มใหม่ แล้วสั่งให้แผงวาดใหม่
65// ==========================================================================
66async function touchFile($: EngineInterface, path: string, status: FileStatus): Promise<void> {
67 const now = await $.clock.now()
68 const key = shortPath(path)
69 const old = files.find(f => f.path === key)
70 const updated: FileInfo = { path: key, status, count: (old?.count ?? 0) + 1, lastTouched: now }
71 files = [...files.filter(f => f.path !== key), updated].slice(-MAX_FILES)
72 $.ui.invalidate('ui.render')
73}
74
75// ==========================================================================
76// บทที่ 2: สร้าง tree แบบข้อความ (ใช้ทั้งในแผงและใน /files list)
77// ==========================================================================
78function buildTreeLines(list: FileInfo[], now: number): string[] {
79 // จัดกลุ่มตามโฟลเดอร์
80 const groups = new Map<string, FileInfo[]>()
81 for (const f of list) {
82 const slash = f.path.lastIndexOf('/')
83 const dir = slash >= 0 ? f.path.slice(0, slash) : '.'
84 groups.set(dir, [...(groups.get(dir) ?? []), f])
85 }
86
87 // โฟลเดอร์ที่แตะล่าสุดขึ้นก่อน, ในโฟลเดอร์ก็เรียงตามเวลาล่าสุด
88 const latest = (list: FileInfo[]) => Math.max(...list.map(f => f.lastTouched))
89 const dirs = [...groups.entries()].sort((a, b) => latest(b[1]) - latest(a[1]))
90
91 const lines: string[] = []
92 for (const [dir, inDir] of dirs) {
93 lines.push(`📁 ${dir}/ (${inDir.length} ไฟล์)`)
94 for (const f of [...inDir].sort((a, b) => b.lastTouched - a.lastTouched)) {
95 const name = f.path.slice(f.path.lastIndexOf('/') + 1)
96 lines.push(` ${STATUS_ICON[f.status]} ${fileIcon(name)} ${name} - ${STATUS_TH[f.status]} x${f.count} (${timeAgo(now - f.lastTouched)})`)
97 }
98 }
99 return lines
100}
101
102// หาไฟล์ที่คำสั่ง rm จะลบ (แบบง่าย: เอาคำที่ไม่ใช่ option หลัง rm)
103function filesRemovedBy(command: string): string[] {
104 const match = /(?:^|[;&|]\s*)rm\s+([^;&|]+)/.exec(command)
105 return match?.[1]?.split(/\s+/).filter(arg => arg && !arg.startsWith('-')) ?? []
106}
107
108export const register: Register = on => {
109 on('session.start', async ($, e, next) => {
110 projectRoot = e.cwd
111 files = []
112 await $.command.register({ name: 'files', description: 'เปิดแผงไฟล์ที่ Claude แตะ (/files list, /files clear)', argumentHint: '[list|clear]' })
113 return next(e)
114 })
115
116 // ==========================================================================
117 // บทที่ 3: ดักทุก tool ที่แตะไฟล์ - หัวใจของ Filetree
118 // ==========================================================================
119 // จดหลังจาก tool รันสำเร็จเท่านั้น (ถ้าถูกปฏิเสธหรือ error ไม่นับ)
120 on('tool.call', async ($, e, next) => {
121 const existedBefore = e.tool === 'Write' ? await $.fs.exists(e.file_path) : true
122 const ran = await next(e)
123 if (ran.deny !== undefined || ran.isError) return ran
124
125 if (e.tool === 'Read') await touchFile($, e.file_path, 'read')
126 if (e.tool === 'Edit') await touchFile($, e.file_path, 'edited')
127 if (e.tool === 'NotebookEdit') await touchFile($, e.notebook_path, 'edited')
128 if (e.tool === 'Write') await touchFile($, e.file_path, existedBefore ? 'edited' : 'created')
129 if (e.tool === 'Bash') {
130 for (const file of filesRemovedBy(e.command)) await touchFile($, file, 'deleted')
131 }
132 return ran
133 })
134
135 // ==========================================================================
136 // บทที่ 4: วาดแผงด้านข้าง (Pane)
137 // ==========================================================================
138 // engine เรียก hook นี้ใหม่ทุกครั้งที่ touchFile เรียก $.ui.invalidate
139 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
140 const { Box, Text } = $.ui.resolve(e)
141 const lines = buildTreeLines(files, await $.clock.now())
142 const room = Math.max(1, e.props.scroll.bodyRows - 1)
143
144 return (
145 <Box flexDirection="column">
146 {lines.length === 0 && <Text dimColor>Claude ยังไม่ได้แตะไฟล์ไหนเลย</Text>}
147 {lines.slice(0, room).map(line => (
148 <Text dimColor={!line.startsWith('📁')} wrap="truncate-end">
149 {line}
150 </Text>
151 ))}
152 {lines.length > room && <Text dimColor>... อีก {lines.length - room} บรรทัด (/files list)</Text>}
153 </Box>
154 )
155 })
156
157 on('command.run', { command: 'files' }, async ($, e) => {
158 const arg = e.args.trim()
159
160 if (arg === 'clear') {
161 files = []
162 $.ui.invalidate('ui.render')
163 return { text: '🧹 ล้างรายการไฟล์แล้ว' }
164 }
165 if (arg === 'list') {
166 const lines = buildTreeLines(files, await $.clock.now())
167 return { text: [`📂 ไฟล์ที่ Claude แตะ (${files.length} ไฟล์)`, '----------------------------------------', ...lines].join('\n') }
168 }
169
170 const opened = await $.ui.open({ id: PANE, title: '📂 ไฟล์ที่ Claude แตะ' })
171 return { text: opened.isPlaced ? '📂 เปิดแผงไฟล์แล้ว' : `เปิดแผงไม่ได้ (${opened.reason}) ลอง /files list แทน` }
172 })
173}
174
175/**
176 * ========================================================================
177 * การบ้าน / ต่อยอด
178 * ========================================================================
179 * 1. เพิ่ม Button ในแต่ละแถว กดแล้ว $.prompt.fill({ text: "@" + path }) เพื่อ mention ไฟล์นั้น
180 * 2. นับ Grep/Glob ด้วย (เทียบด้วย String(e.tool) === "Grep" เพราะบาง build ไม่มี tool นี้)
181 * 3. เก็บรายการลง $.store เพื่อดูย้อนหลังข้าม session
182 */
183