SLOPSHOPPER

filetree-th

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

newpaneguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · filetree-th
│ ┃ 📂 ไฟล์ที่ Claude แตะ ✕ › fix the failing auth test and add an audit log call │ ┃ 📁 ./ (1 ไฟล์) │ ┃ 🗑️ 📄 build - ลบ x1 (0วิที่แล้ว) ⏺ Read(src/auth.ts) │ ┃ 📁 src/ (3 ไฟล์) ⎿ Read 6 lines │ ┃ ✨ 📘 cache.ts - สร้าง x1 (0วิที่แล้ว) ⏺ Update(src/auth.ts) │ ┃ ✨ 📘 audit.ts - สร้าง x1 (0วิที่แล้ว) ⎿ Added 2 lines, removed 1 line │ ┃ ✏️ 📘 auth.ts - แก้ไข x2 (0วิที่แล้ว) ⏺ 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 │ ⎿ filetree-th: 📂 เปิดแผงไฟล์แล้ว │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 📂 ไฟล์ที่ Claude แตะ
📁 ./ (1 ไฟล์) 🗑️ 📄 build - ลบ x1 (0วิที่แล้ว) 📁 src/ (3 ไฟล์) ✨ 📘 cache.ts - สร้าง x1 (0วิที่แล้ว) ✨ 📘 audit.ts - สร้าง x1 (0วิที่แล้ว) ✏️ 📘 auth.ts - แก้ไข x2 (0วิที่แล้ว)
README

Mod 04: Filetree TH

แผงด้านข้างที่แสดงทุกไฟล์ที่ 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 แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:

  1. จดเฉพาะ tool ที่สำเร็จ (เช็ค deny และ isError หลัง next)
  2. เช็คก่อน Write ว่าไฟล์มีอยู่แล้วหรือไม่ด้วย $.fs.exists
  3. เปิดแผงด้วย $.ui.open และวาดด้วย ui.render บน Pane
  4. สั่งวาดใหม่ด้วย $.ui.invalidate

หมายเหตุ

การตรวจ rm ใน Bash เป็นแบบง่าย อ่านแค่ชื่อไฟล์หลังคำสั่ง rm

Source 1 files
hooks/register.tsx 183 lines
1/**
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