SLOPSHOPPER

git-diff-button

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

newpanebandguardprocess
v0.1.0no licenseupdated 2026-10-15RRRRRRR-777/ClaudeCodeSettings/mods/git-diff-button
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-diff-button
│ ┃ git-diff ✕ › fix the failing auth test and add an audit log call │ ┃ No changes. │ ⏺ 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 │ │ │ │ ⟨Claude Code's own drawing⟩ [ diff ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ [ diff ]
Pane · git-diff
No changes.
README

ClaudeCodeSettings

Source 2 files
hooks/register.tsx 167 lines
1import { 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}
167
types/index.d.ts 14 lines
1export 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