入力欄上の右端のボタンで、git diff を処理中でも開くペイン

hooks/register.tsx 167 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FileDiff } from '../types'
5
6const PANE = 'git-diff'
7const MAX_LINES = 2000
8const MAX_LINES_PER_NEW_FILE = 300
9const MAX_NEW_FILES = 50
10const REFRESH_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash'])
11
12const files = atom({ plugin: 'git-diff-button', key: 'files' } as const, [] as FileDiff[])
13const selected = atom({ plugin: 'git-diff-button', key: 'selected' } as const, null as string | null)
14
15const splitLines = (text: string) => (text === '' ? [] : text.replace(/\n$/, '').split('\n'))
16
17export const capNewFile = (diff: string): string[] => {
18 const lines = splitLines(diff)
19 if (lines.length <= MAX_LINES_PER_NEW_FILE) return lines
20
21 return [
22 ...lines.slice(0, MAX_LINES_PER_NEW_FILE),
23 `… ${lines.length - MAX_LINES_PER_NEW_FILE} more lines in this file`,
24 ]
25}
26
27const CONTROL = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g
28
29export const stripControl = (line: string) =>
30 line
31 .replace(/\r$/, '')
32 .replace(CONTROL, c => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
33
34export const parseDiff = (raw: string[]): FileDiff[] => {
35 const lines = raw.map(stripControl)
36 const starts = lines.flatMap((line, i) => (line.startsWith('diff --git ') ? [i] : []))
37
38 return starts.map((from, n) => {
39 const body = lines.slice(from, starts[n + 1] ?? lines.length)
40
41 return {
42 path: body[0].replace(/^diff --git a\/.* b\//, ''),
43 lines: body.slice(0, MAX_LINES),
44 added: body.filter(line => line.startsWith('+') && !line.startsWith('+++')).length,
45 removed: body.filter(line => line.startsWith('-') && !line.startsWith('---')).length,
46 isTruncated: body.length > MAX_LINES,
47 }
48 })
49}
50
51export const lineColor = (line: string) => {
52 if (line.startsWith('+++') || line.startsWith('---')) return undefined
53 if (line.startsWith('+')) return 'green'
54 if (line.startsWith('-')) return 'red'
55 if (line.startsWith('@@')) return 'cyan'
56
57 return undefined
58}
59
60async function refresh($: EngineInterface) {
61 const [tracked, others] = await Promise.all([
62 $.process.run(['git', 'diff', 'HEAD', '--no-color']),
63 $.process.run(['git', 'ls-files', '-z', '--others', '--exclude-standard']),
64 ])
65 const paths = others.stdout.split('\0').filter(path => path !== '').slice(0, MAX_NEW_FILES)
66 const added = await Promise.all(
67 paths.map(path =>
68 $.process.run(['git', 'diff', '--no-index', '--no-color', '--', '/dev/null', path]),
69 ),
70 )
71 const lines = [...splitLines(tracked.stdout), ...added.flatMap(one => capNewFile(one.stdout))]
72 await update($, files, () => parseDiff(lines))
73}
74
75async function show($: EngineInterface, path: string | null, focusKey: string) {
76 await update($, selected, () => path)
77 await $.ui.focus({ requestId: PANE, key: focusKey }).catch(() => undefined)
78}
79
80export const register: Register = on => {
81 let isWatching = false
82
83 on('tool.call', async ($, e, next) => {
84 const ran = await next(e)
85 if (isWatching && REFRESH_TOOLS.has(e.tool)) {
86 await refresh($).catch(() => undefined)
87 }
88
89 return ran
90 })
91
92 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
93 const { Box, Button } = $.ui.resolve(e)
94 const rest = await next(e)
95
96 return (
97 <Box flexDirection="column">
98 {rest}
99 <Box justifyContent="flex-end">
100 <Button
101 key="open-diff"
102 label="diff"
103 dimColor
104 onPress={async () => {
105 isWatching = true
106 await refresh($)
107 await update($, selected, () => null)
108 await $.ui.open({ id: PANE, title: 'diff', focus: true, closeOnEscape: true })
109 }}
110 />
111 </Box>
112 </Box>
113 )
114 })
115
116 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
117 const { Box, Button, Text } = $.ui.resolve(e)
118 const all = await read($, files)
119 const path = await read($, selected)
120 const file = all.find(one => one.path === path)
121
122 if (all.length === 0) {
123 return <Text dimColor>No changes.</Text>
124 }
125
126 if (file === undefined) {
127 return (
128 <Box flexDirection="column">
129 <Text bold>{all.length} files changed</Text>
130 {all.map((one, i) => (
131 <Button
132 key={one.path}
133 autoFocus={i === 0 ? true : undefined}
134 onPress={() => show($, one.path, 'back')}
135 >
136 <Text>{one.path} </Text>
137 <Text color="green">+{one.added} </Text>
138 <Text color="red">-{one.removed}</Text>
139 </Button>
140 ))}
141 </Box>
142 )
143 }
144
145 return (
146 <Box flexDirection="column">
147 <Box gap={1}>
148 <Button
149 key="back"
150 label="< files"
151 hotkey="b"
152 autoFocus
153 onPress={() => show($, null, file.path)}
154 />
155 <Text bold>{file.path}</Text>
156 </Box>
157 {file.lines.map(line => (
158 <Text color={lineColor(line)} dimColor={line.startsWith('diff --git')}>
159 {line}
160 </Text>
161 ))}
162 {file.isTruncated && <Text dimColor>… cut at {MAX_LINES} lines</Text>}
163 </Box>
164 )
165 })
166}
167types/index.d.ts 14 lines1export type FileDiff = {
2 path: string
3 lines: string[]
4 added: number
5 removed: number
6 isTruncated: boolean
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'git-diff-button': { files: FileDiff[]; selected: string | null }
12 }
13}
14