git の差分を GitHub の Changes のようにペインで見て (split / unified)、行を選んでコメントし、まとめて Claude に渡す mod

開発ワークフロー支援のためのClaude Codeプラグイン
このプラグインは、日常的な開発ワークフローを効率化するための機能を提供します。
cmux.json 編集トリガー例: 「cmux起動して」「cmuxのワークスペース一覧」「cmuxにキー送って」「cmuxのブラウザをスクショ」
トリガー例: 「difitでレビュー」「difit立ち上げて」「difitのコメント反映して」
Git worktreeを使った並行開発を支援します。
トリガー例:
プロジェクトのテストフレームワークを自動検出し、テストを実行します。
対応フレームワーク:
トリガー例:
コード変更のセルフレビューを支援します。
レビュー観点:
トリガー例:
本体とは別のプラグインとして mods/ に置いている。Claude Code の mod (function hooks) は early access で、API はリリースごとに変わる。
/diff-review で git の差分をペインに表示。GitHub の Changes に近い操作感w で切り詰め表示に切り替えられ、この設定も次のセッションに残るbranch (origin の既定ブランチとの merge-base) と uncommitted (HEAD)。untracked ファイルも含むorigin/feature-x などのリモート) を選べる。未指定なら origin の既定ブランチ。どれを選んでもそのブランチとの merge-base と比べる。プルダウンに出るのはコミットの新しい順に 64 件までなので、出てこないブランチは /diff-review base <branch> で指定するpath:行: 本文 の箇条書きにしてプロンプトへ入れる (送信は Enter)go.sum、*.pb.go、*.min.js、vendor/ 等)、先頭20行の @generated / DO NOT EDIT、.gitattributes の linguist-generated で判定し、g で表示を切り替える| 操作 | キー / 入力 |
|---|---|
| branch / uncommitted | b / u |
| split / unified | p / n |
| 折り返しの切り替え | w |
| 自動生成ファイルの表示 / 非表示 | g |
| 更新 | r |
| Claude に渡す | s |
| 行指定でコメント | 下の入力欄に R12 本文、L3-5 本文 (L=変更前、R=変更後) |
| 引数 | /diff-review branch、uncommitted、base <branch> (base だけで既定に戻す)、split、unified、wrap、generated、close |
/plugin install diff-review@ei-plugins
# 開発中はフォルダを直接読み込む (保存するとホットリロードされる)
claude --plugin-dir ./mods/diff-review
開発時の確認:
claude plugin validate mods/diff-review
claude plugin test mods/diff-review
このリポジトリは .claude-plugin/marketplace.json を持つため、GitHubリポジトリを直接マーケットプレイスとして追加できる。
# GitHubリポジトリをマーケットプレイスとして追加
/plugin marketplace add ei-sugimoto/ei-claude-code-plugins
# プラグインをインストール
/plugin install ei-claude-code-plugins@ei-plugins
# インストール確認
/plugin list
または、.claude/settings.json(グローバル)か .claude/settings.local.json(プロジェクト)に直接記述:
{
"extraKnownMarketplaces": {
"ei-plugins": {
"source": {
"source": "github",
"repo": "ei-sugimoto/ei-claude-code-plugins"
}
}
},
"enabledPlugins": {
"ei-claude-code-plugins@ei-plugins": true
}
}
/plugin marketplace add /Users/ei.sugimoto/works/github.com/ei-sugimoto/ei-claude-code-plugins
/plugin install ei-claude-code-plugins@ei-plugins
claude --plugin-dir /Users/ei.sugimoto/works/github.com/ei-sugimoto/ei-claude-code-plugins
brew install --cask cmuxMIT
hooks/register.tsx 840 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {
5 DiffReviewCell,
6 DiffReviewComment,
7 DiffReviewFile,
8 DiffReviewRow,
9 DiffReviewMode,
10 DiffReviewSide,
11 DiffReviewSnapshot,
12 DiffReviewTarget,
13 DiffReviewView,
14} from '../types'
15import {
16 buildTree,
17 commentKey,
18 displayWidth,
19 EXPAND_STEP,
20 expansionKey,
21 fillWidth,
22 fitWidth,
23 formatComments,
24 gapsOf,
25 headerOf,
26 HEADER_LINES,
27 isGeneratedHeader,
28 isGeneratedPath,
29 isInRange,
30 limitOptions,
31 lineLabel,
32 nextTarget,
33 parseBranches,
34 parseCheckAttr,
35 parseCommentInput,
36 parseUnifiedDiff,
37 revealGap,
38 sanitize,
39 sidebarWidth,
40 splitLines,
41 toUnified,
42 untrackedFile,
43 wrapWidth,
44} from './diff'
45
46const PANE = 'diff-review'
47const MAX_UNTRACKED = 30
48const MAX_UNTRACKED_BYTES = 200_000
49// 差分のない行を展開するために読むファイルの上限
50const MAX_SOURCE_BYTES = 500_000
51const GUTTER = 5
52// 選択中の行の背景。赤や緑の文字が読める暗めの青
53const SELECTED_BG = '#302714'
54const CURRENT_FILE_BG = '#262c36'
55const DIFF_BG = {
56 add: { line: '#12261e', num: '#1c4428' },
57 del: { line: '#25171c', num: '#542426' },
58} as const
59const HUNK_BG = '#121d2f'
60const HUNK_NUM_BG = '#0c2d6b'
61const EMPTY_BG = '#151b23'
62const CANVAS_BG = '#0d1117'
63const FG = '#f0f6fc'
64const MUTED = '#9198a1'
65
66const mode = atom({ plugin: 'diff-review', key: 'mode' } as const, 'branch')
67const snapshot = atom({ plugin: 'diff-review', key: 'snapshot' } as const, null)
68const selected = atom({ plugin: 'diff-review', key: 'selected' } as const, null)
69const comments = atom({ plugin: 'diff-review', key: 'comments' } as const, [])
70// GitHub の「+」と同じく、行番号をクリックした行にコメント欄を開く
71const target = atom({ plugin: 'diff-review', key: 'target' } as const, null)
72
73const INLINE_INPUT = 'inline-comment'
74// split (左右) と unified (1列) のどちらで描くか。セッションをまたいで $.store にも残す
75const view = atom({ plugin: 'diff-review', key: 'view' } as const, 'split')
76const VIEW_STORE_KEY = 'view'
77
78// 長い行は GitHub と同じく既定で折り返す。切り替えは $.store にも残す
79const wrapped = atom({ plugin: 'diff-review', key: 'isWrapped' } as const, true)
80const WRAP_STORE_KEY = 'isWrapped'
81
82// 折りたたんだディレクトリのパス
83const collapsed = atom({ plugin: 'diff-review', key: 'collapsed' } as const, [])
84
85const showGenerated = atom({ plugin: 'diff-review', key: 'showGenerated' } as const, false)
86
87// branch モードで比べる相手。null なら既定ブランチを探す
88const baseBranch = atom({ plugin: 'diff-review', key: 'baseBranch' } as const, null)
89const DEFAULT_BASE = ':default'
90const MAX_BRANCHES = 200
91
92// hunk の間で展開した行数。行番号がずれるので比べる対象を変えたら捨てる
93const expanded = atom({ plugin: 'diff-review', key: 'expanded' } as const, {})
94
95// これより狭いペインではサイドバーをやめ、ファイルをプルダウンで選ぶ
96const SIDEBAR_MIN_COLUMNS = 80
97// サイドバーと左右の差分が並ぶよう、横に置くペインにはこの幅を求める (手で広げた幅があればそちらが優先)
98const WANTED_COLUMNS = 140
99
100type Base = { ref: string; label: string }
101
102const git = ($: EngineInterface, args: string[]) => $.process.run(['git', ...args])
103
104const firstLine = (text: string): string => text.trim().split('\n')[0] ?? ''
105
106// branch モードは選んだブランチ (未指定なら origin の既定ブランチ) との merge-base、見つからなければ HEAD と比べる
107// GitHub の PR と同じく、比較元が先に進んでいてもこのブランチで入れた変更だけが出る
108const resolveBase = async ($: EngineInterface, current: DiffReviewMode, chosen: string | null): Promise<Base> => {
109 if (current === 'uncommitted') return { ref: 'HEAD', label: 'HEAD (未コミット)' }
110
111 const head = await git($, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])
112 const candidates =
113 chosen !== null ? [chosen] : [firstLine(head.stdout), 'origin/main', 'origin/master', 'main', 'master'].filter(c => c !== '')
114 for (const candidate of candidates) {
115 const verified = await git($, ['rev-parse', '--verify', '--quiet', candidate])
116 if (verified.exitCode !== 0) continue
117 const mergeBase = await git($, ['merge-base', 'HEAD', candidate])
118 if (mergeBase.exitCode !== 0) continue
119 const sha = firstLine(mergeBase.stdout)
120 return { ref: sha, label: `${candidate} (merge-base ${sha.slice(0, 7)})` }
121 }
122 return { ref: 'HEAD', label: chosen !== null ? `HEAD (${chosen} が見つからない)` : 'HEAD (既定ブランチが見つからない)' }
123}
124
125const loadBranches = async ($: EngineInterface): Promise<string[]> => {
126 const listed = await git($, ['for-each-ref', '--sort=-committerdate', '--format=%(refname)', 'refs/heads', 'refs/remotes'])
127 return listed.exitCode === 0 ? parseBranches(listed.stdout).slice(0, MAX_BRANCHES) : []
128}
129
130const loadUntracked = async ($: EngineInterface, root: string): Promise<DiffReviewFile[]> => {
131 const listed = await git($, ['ls-files', '--others', '--exclude-standard', '--full-name', '-z'])
132 if (listed.exitCode !== 0) return []
133 const paths = listed.stdout.split('\u0000').filter(p => p !== '').slice(0, MAX_UNTRACKED)
134 const files: DiffReviewFile[] = []
135 for (const path of paths) {
136 try {
137 const stat = await $.fs.stat(`${root}/${path}`)
138 if (stat.size > MAX_UNTRACKED_BYTES) continue
139 const content = await $.fs.read(`${root}/${path}`)
140 if (typeof content === 'string' && !content.includes('\u0000')) files.push(untrackedFile(path, content))
141 } catch {
142 // 読めないファイルは一覧に出さない
143 }
144 }
145 return files
146}
147
148const readHeader = async ($: EngineInterface, root: string, path: string): Promise<string[]> => {
149 try {
150 const stat = await $.fs.stat(`${root}/${path}`)
151 if (stat.size > MAX_UNTRACKED_BYTES) return []
152 const content = await $.fs.read(`${root}/${path}`)
153 return typeof content === 'string' ? content.split('\n', HEADER_LINES) : []
154 } catch {
155 return []
156 }
157}
158
159const markGenerated = async ($: EngineInterface, root: string, files: DiffReviewFile[]): Promise<DiffReviewFile[]> => {
160 const candidates = files.filter(f => !isGeneratedPath(f.path)).map(f => f.path)
161 const attr =
162 candidates.length === 0
163 ? new Set<string>()
164 : parseCheckAttr((await $.process.run(['git', 'check-attr', '-z', 'linguist-generated', '--', ...candidates], { cwd: root })).stdout)
165 return Promise.all(
166 files.map(async f => {
167 if (isGeneratedPath(f.path) || attr.has(f.path)) return { ...f, isGenerated: true }
168 const lines = headerOf(f) ?? (f.isBinary ? [] : await readHeader($, root, f.path))
169 return { ...f, isGenerated: isGeneratedHeader(f.path, lines) }
170 }),
171 )
172}
173
174// git diff は base と作業ツリーを比べるので、変更後の中身は作業ツリーから読める
175const loadSources = ($: EngineInterface, root: string, files: DiffReviewFile[]): Promise<DiffReviewFile[]> =>
176 Promise.all(
177 files.map(async f => {
178 if (f.isBinary || f.isUntracked || f.hunks.length === 0) return f
179 try {
180 const stat = await $.fs.stat(`${root}/${f.path}`)
181 if (stat.size > MAX_SOURCE_BYTES) return f
182 const content = await $.fs.read(`${root}/${f.path}`)
183 return typeof content === 'string' ? { ...f, source: splitLines(content).map(sanitize) } : f
184 } catch {
185 // 削除したファイルなどは展開しない
186 return f
187 }
188 }),
189 )
190
191const visibleFiles = (files: readonly DiffReviewFile[], isShown: boolean): DiffReviewFile[] =>
192 isShown ? [...files] : files.filter(f => f.isGenerated !== true)
193
194const takeSnapshot = async ($: EngineInterface, current: DiffReviewMode, chosen: string | null): Promise<DiffReviewSnapshot> => {
195 const top = await git($, ['rev-parse', '--show-toplevel'])
196 if (top.exitCode !== 0) return { baseLabel: '-', files: [], error: 'git リポジトリの中ではありません' }
197 const root = firstLine(top.stdout)
198
199 const base = await resolveBase($, current, chosen)
200 const branches = current === 'branch' ? await loadBranches($) : []
201 const diff = await $.process.run(
202 ['git', 'diff', '--no-color', '--no-ext-diff', '--find-renames', '-U3', base.ref],
203 { cwd: root },
204 )
205 if (diff.exitCode !== 0) {
206 return { baseLabel: base.label, branches, files: [], error: firstLine(diff.stderr) || 'git diff が失敗しました' }
207 }
208 const parsed = await loadSources($, root, parseUnifiedDiff(diff.stdout))
209 const files = await markGenerated($, root, [...parsed, ...(await loadUntracked($, root))])
210 return {
211 baseLabel: base.label,
212 branches,
213 files,
214 error: diff.isStdoutTruncated ? '差分が大きすぎるため途中までしか表示していません' : null,
215 }
216}
217
218let isRefreshing = false
219
220const reselect = async ($: EngineInterface, files: readonly DiffReviewFile[]) => {
221 await update($, selected, path => (path !== null && files.some(f => f.path === path) ? path : (files[0]?.path ?? null)))
222}
223
224const refresh = async ($: EngineInterface): Promise<void> => {
225 if (isRefreshing) return
226 isRefreshing = true
227 try {
228 const next = await takeSnapshot($, await read($, mode), await read($, baseBranch))
229 await update($, snapshot, () => next)
230 await reselect($, visibleFiles(next.files, await read($, showGenerated)))
231 } finally {
232 isRefreshing = false
233 }
234}
235
236const toggleGenerated = async ($: EngineInterface) => {
237 const isShown = !(await read($, showGenerated))
238 await update($, showGenerated, () => isShown)
239 await reselect($, visibleFiles((await read($, snapshot))?.files ?? [], isShown))
240}
241
242// 比較元を変えると行番号がずれるので、展開した行は捨てる
243const setBaseBranch = async ($: EngineInterface, value: string | null) => {
244 await update($, baseBranch, () => value)
245 await update($, mode, () => 'branch')
246 await update($, expanded, () => ({}))
247}
248
249const isPaneOpen = async ($: EngineInterface): Promise<boolean> =>
250 (await $.ui.panes()).some(pane => pane.id === PANE)
251
252const setView = async ($: EngineInterface, value: DiffReviewView) => {
253 await update($, view, () => value)
254 await $.store.set(VIEW_STORE_KEY, value)
255}
256
257const toggleWrap = async ($: EngineInterface) => {
258 const isWrapped = !(await read($, wrapped))
259 await update($, wrapped, () => isWrapped)
260 await $.store.set(WRAP_STORE_KEY, isWrapped)
261}
262
263const newId = (): string => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
264
265export const register: Register = on => {
266 on('session.start', async ($, e, next) => {
267 await $.command.register({
268 name: 'diff-review',
269 description: 'git の差分をペインで開き、行コメントを Claude に渡す',
270 argumentHint: '[branch|uncommitted|base <branch>|split|unified|wrap|generated|close]',
271 })
272 const saved = await $.store.get(VIEW_STORE_KEY)
273 if (saved === 'split' || saved === 'unified') await update($, view, () => saved)
274 const savedWrap = await $.store.get(WRAP_STORE_KEY)
275 if (typeof savedWrap === 'boolean') await update($, wrapped, () => savedWrap)
276 // ホットリロードでも session.start が走る。開いたままのペインを新しいコードで描き直す
277 $.ui.invalidate('ui.render')
278 return next(e)
279 })
280
281 on('command.run', { command: 'diff-review' }, async ($, e) => {
282 const arg = e.args.trim()
283 if (arg === 'close') {
284 await $.ui.close({ id: PANE })
285 return { text: 'diff-review を閉じました' }
286 }
287 if (arg === 'branch' || arg === 'uncommitted') {
288 await update($, mode, () => arg)
289 await update($, expanded, () => ({}))
290 }
291 // "base origin/feature" で比較元を選ぶ。"base" だけなら既定ブランチに戻す
292 const baseArg = /^base(?:\s+(\S+))?$/.exec(arg)
293 if (baseArg !== null) await setBaseBranch($, baseArg[1] ?? null)
294 if (arg === 'split' || arg === 'unified') await setView($, arg)
295 if (arg === 'wrap') await toggleWrap($)
296 if (arg === 'generated') await update($, showGenerated, isShown => !isShown)
297
298 const opened = await $.ui.open({ id: PANE, title: 'diff review', focus: true, columns: WANTED_COLUMNS })
299 await refresh($)
300 if (!opened.isPlaced) return { text: 'diff-review: ペインを置けませんでした。ターミナルを広げてください' }
301 return { text: `diff-review を開きました (${await read($, mode)})` }
302 })
303
304 // 編集が入ったターンの終わりに、開いていれば差分を取り直す
305 on('turn.complete', async ($, e, next) => {
306 const result = await next(e)
307 if (await isPaneOpen($)) await refresh($)
308 return result
309 })
310
311 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
312 // mobile には Input と Select がないので、端末かデスクトップで開くよう案内だけ出す
313 if (e.surface === 'mobile') {
314 const { Text } = $.ui.resolve(e)
315 return <Text dimColor>diff-review は端末かデスクトップで開いてください</Text>
316 }
317 const { Box, Text, Button, Input, Select } = $.ui.resolve(e)
318 const current = await read($, mode)
319 const snap = await read($, snapshot)
320 const path = await read($, selected)
321 // $.state はリロードをまたいで残るので、行範囲になる前の形で保存されたコメントは捨てる
322 const notes = (await read($, comments)).filter(note => typeof note.start === 'number')
323 const aimed = await read($, target)
324 const closedDirs = await read($, collapsed)
325 const shape = await read($, view)
326 const isWrapped = await read($, wrapped)
327 const opened = await read($, expanded)
328 const chosenBase = await read($, baseBranch)
329 const isGeneratedShown = await read($, showGenerated)
330 const files = visibleFiles(snap?.files ?? [], isGeneratedShown)
331 const generatedCount = (snap?.files ?? []).filter(f => f.isGenerated === true).length
332 const columns = Math.max(40, e.props.bodyColumns)
333 const hasSidebar = columns >= SIDEBAR_MIN_COLUMNS
334 const noteCount = new Map<string, number>()
335 for (const note of notes) noteCount.set(note.path, (noteCount.get(note.path) ?? 0) + 1)
336 const sidebarTitle = `変更ファイル (${files.length})`
337 const tree = buildTree(files, closedDirs).map(row => {
338 const indent = ' '.repeat(row.depth)
339 if (row.kind === 'dir') return { row, label: `${indent}${row.isCollapsed ? '+' : '-'} ${row.name}`, suffix: '', added: '', removed: '', mark: '', gen: '' }
340 const count = noteCount.get(row.path) ?? 0
341 const added = ` +${row.file.added}`
342 const removed = row.file.isUntracked ? ' new' : ` -${row.file.removed}`
343 const mark = count > 0 ? ` *${count}` : ''
344 const gen = row.file.isGenerated === true ? ' gen' : ''
345 return { row, label: `${indent} ${row.name}`, suffix: added + removed + mark + gen, added, removed, mark, gen }
346 })
347 const sideWidth = hasSidebar ? sidebarWidth([sidebarTitle, ...tree.map(t => t.label + t.suffix)], columns) : 0
348 const diffWidth = hasSidebar ? columns - sideWidth - 1 : columns
349 const half = Math.floor((diffWidth - 1) / 2)
350 const file = files.find(f => f.path === path) ?? null
351
352 // 本文は範囲の最終行の下に出し、範囲内の行には印を付ける
353 const byEndLine = new Map<string, DiffReviewComment[]>()
354 const noted = new Set<string>()
355 for (const note of notes) {
356 const key = commentKey(note.path, note.side, note.end)
357 byEndLine.set(key, [...(byEndLine.get(key) ?? []), note])
358 for (let line = note.start; line <= note.end; line++) noted.add(commentKey(note.path, note.side, line))
359 }
360
361 const setMode = (value: DiffReviewMode) => async () => {
362 await update($, mode, () => value)
363 await update($, expanded, () => ({}))
364 await refresh($)
365 }
366
367 const aim = async (path: string, side: DiffReviewSide, line: number) => {
368 const next = nextTarget(aimed, path, side, line)
369 await update($, target, () => next)
370 if (!next.isEditing) return
371 // フォーカスを移せない場面 (ペインがキーを持っていない等) でも入力欄は開いたままにする
372 await $.ui.focus({ requestId: PANE, key: INLINE_INPUT }).catch(() => undefined)
373 }
374
375 // 記号のあとの本文を幅 width で折り返し、2行目以降は記号の列を空ける。折り返さないときは1行に切り詰める
376 const bodyLines = (sign: string, text: string, width: number): string[] =>
377 isWrapped ? wrapWidth(text, width - 1).map((chunk, i) => `${i === 0 ? sign : ' '}${chunk}`) : [fillWidth(`${sign}${text}`, width)]
378
379 const signOf = (kind: DiffReviewCell['kind']) => (kind === 'add' ? '+' : kind === 'del' ? '-' : ' ')
380
381 const cellLines = (value: DiffReviewCell | null): string[] =>
382 value === null ? [] : bodyLines(signOf(value.kind), value.text, half - GUTTER)
383
384 // 左右で折り返した行数が違うときは、短い側を height 行まで空行で埋めて高さを揃える
385 const cell = (path: string, side: DiffReviewSide, value: DiffReviewCell | null, lines: readonly string[], height: number) => {
386 if (value === null) {
387 return (
388 <Box width={half} flexDirection="column" backgroundColor={EMPTY_BG}>
389 {Array.from({ length: height }, () => (
390 <Text backgroundColor={EMPTY_BG}>{' '.repeat(half)}</Text>
391 ))}
392 </Box>
393 )
394 }
395 const isTarget = isInRange(aimed, path, side, value.no)
396 const hasNote = noted.has(commentKey(path, side, value.no))
397 const tone = value.kind === 'ctx' ? undefined : DIFF_BG[value.kind]
398 const bg = isTarget ? SELECTED_BG : (tone?.line ?? CANVAS_BG)
399 const numBg = isTarget ? SELECTED_BG : (tone?.num ?? CANVAS_BG)
400 const padded = [...lines, ...Array.from({ length: height - lines.length }, () => ' '.repeat(half - GUTTER))]
401 return (
402 <Box width={half} flexDirection="column" backgroundColor={bg}>
403 {padded.map((line, i) => (
404 <Box flexDirection="row" backgroundColor={bg}>
405 {i === 0 ? (
406 <Box backgroundColor={numBg}>
407 <Button
408 key={`ln-${side}-${value.no}`}
409 label={String(value.no).padStart(GUTTER - 1)}
410 plain
411 dimColor={tone === undefined && !isTarget && !hasNote}
412 onPress={() => aim(path, side, value.no)}
413 />
414 </Box>
415 ) : (
416 <Text backgroundColor={numBg}>{' '.repeat(GUTTER - 1)}</Text>
417 )}
418 <Text color={isTarget ? 'cyan' : 'yellow'} backgroundColor={numBg} bold={isTarget}>
419 {i === 0 && isTarget ? '>' : i === 0 && hasNote ? '*' : ' '}
420 </Text>
421 <Text color={FG} backgroundColor={bg} bold={isTarget} wrap="truncate-end">
422 {line}
423 </Text>
424 </Box>
425 ))}
426 </Box>
427 )
428 }
429
430 const submitInline = async (value: string) => {
431 const body = value.trim()
432 if (aimed === null || !aimed.isEditing || body === '') return
433 const { path: notePath, side, start, end } = aimed
434 const note: DiffReviewComment = { id: newId(), path: notePath, side, start, end, body }
435 await update($, comments, list => [...list, note])
436 await update($, target, () => null)
437 }
438
439 const inlineEditor = (aimedAt: DiffReviewTarget) => (
440 <Box flexDirection="column" marginLeft={GUTTER + 1} borderStyle="round" borderColor="cyan" paddingX={1}>
441 <Text dimColor>
442 {aimedAt.path}:{lineLabel(aimedAt.start, aimedAt.end)} ({aimedAt.side === 'L' ? '変更前' : '変更後'}) にコメント
443 </Text>
444 <Input key={INLINE_INPUT} placeholder="コメントを書いて Enter" submitLabel="追加" autoFocus onSubmit={submitInline} />
445 <Box flexDirection="row">
446 <Button key="inline-cancel" label="キャンセル" plain dimColor onPress={() => update($, target, () => null)} />
447 </Box>
448 </Box>
449 )
450
451 // 行の下に出すもの: その行で終わるコメントと、その行で終わる選択範囲の入力欄
452 const under = (path: string, oldNo: number | null, newNo: number | null) => {
453 const attached = [
454 ...(oldNo !== null ? (byEndLine.get(commentKey(path, 'L', oldNo)) ?? []) : []),
455 ...(newNo !== null ? (byEndLine.get(commentKey(path, 'R', newNo)) ?? []) : []),
456 ]
457 const isEditing =
458 aimed !== null &&
459 aimed.isEditing &&
460 aimed.path === path &&
461 ((aimed.side === 'L' && oldNo === aimed.end) || (aimed.side === 'R' && newNo === aimed.end))
462 return [
463 ...attached.map(note => (
464 <Text color="yellow" wrap="wrap">
465 {' '}
466 {note.side}
467 {lineLabel(note.start, note.end)}: {note.body}
468 </Text>
469 )),
470 ...(isEditing && aimed !== null ? [inlineEditor(aimed)] : []),
471 ]
472 }
473
474 // unified は変更前と変更後の行番号を2列並べ、どちらを押してもその側の行を選ぶ
475 const lineNumber = (path: string, side: DiffReviewSide, no: number | null, isLit: boolean, numBg: string | undefined) =>
476 no === null ? (
477 <Text backgroundColor={numBg}>{' '.repeat(GUTTER - 1)}</Text>
478 ) : (
479 <Box backgroundColor={numBg}>
480 <Button
481 key={`ln-${side}-${no}`}
482 label={String(no).padStart(GUTTER - 1)}
483 plain
484 dimColor={!isLit}
485 onPress={() => aim(path, side, no)}
486 />
487 </Box>
488 )
489
490 const unifiedRows = (shown: DiffReviewFile, rows: readonly DiffReviewRow[], prefix: string) =>
491 toUnified({ header: '', rows: [...rows] }).flatMap((line, i) => {
492 const isTarget =
493 (line.oldNo !== null && isInRange(aimed, shown.path, 'L', line.oldNo)) ||
494 (line.newNo !== null && isInRange(aimed, shown.path, 'R', line.newNo))
495 const hasNote =
496 (line.oldNo !== null && noted.has(commentKey(shown.path, 'L', line.oldNo))) ||
497 (line.newNo !== null && noted.has(commentKey(shown.path, 'R', line.newNo)))
498 const tone = line.kind === 'ctx' ? undefined : DIFF_BG[line.kind]
499 const bg = isTarget ? SELECTED_BG : (tone?.line ?? CANVAS_BG)
500 const numBg = isTarget ? SELECTED_BG : (tone?.num ?? CANVAS_BG)
501 const isLit = tone !== undefined || isTarget || hasNote
502 const gutter = (GUTTER - 1) * 2 + 2
503 return [
504 ...bodyLines(signOf(line.kind), line.text, diffWidth - gutter).map((body, part) => (
505 <Box flexDirection="row" key={`uni-${prefix}-${i}-${part}`} backgroundColor={bg}>
506 {part === 0 ? (
507 [
508 lineNumber(shown.path, 'L', line.oldNo, isLit, numBg),
509 <Text backgroundColor={numBg}> </Text>,
510 lineNumber(shown.path, 'R', line.newNo, isLit, numBg),
511 ]
512 ) : (
513 <Text backgroundColor={numBg}>{' '.repeat(gutter - 1)}</Text>
514 )}
515 <Text color={isTarget ? 'cyan' : 'yellow'} backgroundColor={numBg} bold={isTarget}>
516 {part === 0 && isTarget ? '>' : part === 0 && hasNote ? '*' : ' '}
517 </Text>
518 <Text color={FG} backgroundColor={bg} bold={isTarget} wrap="truncate-end">
519 {body}
520 </Text>
521 </Box>
522 )),
523 ...under(shown.path, line.oldNo, line.newNo),
524 ]
525 })
526
527 const splitRows = (shown: DiffReviewFile, rows: readonly DiffReviewRow[], prefix: string) =>
528 rows.flatMap((row, r) => {
529 const left = cellLines(row.left)
530 const right = cellLines(row.right)
531 const height = Math.max(1, left.length, right.length)
532 return [
533 <Box flexDirection="row" key={`row-${prefix}-${r}`}>
534 {cell(shown.path, 'L', row.left, left, height)}
535 <Box flexDirection="column">
536 {Array.from({ length: height }, () => (
537 <Text color={MUTED} backgroundColor={CANVAS_BG}>│</Text>
538 ))}
539 </Box>
540 {cell(shown.path, 'R', row.right, right, height)}
541 </Box>,
542 ...under(shown.path, row.left?.no ?? null, row.right?.no ?? null),
543 ]
544 })
545
546 const rowsOf = (shown: DiffReviewFile, rows: readonly DiffReviewRow[], prefix: string) =>
547 shape === 'unified' ? unifiedRows(shown, rows, prefix) : splitRows(shown, rows, prefix)
548
549 const expand = (key: string, top: number, bottom: number) =>
550 update($, expanded, all => ({ ...all, [key]: { top, bottom } }))
551
552 // hunk のヘッダ行。前に隠れた行があれば、GitHub と同じく展開ボタンを並べる
553 const hunkHeader = (index: number, header: string, buttons: { key: string; label: string; onPress: () => unknown }[]) => {
554 const used = buttons.reduce((sum, button) => sum + displayWidth(button.label) + 1, 0)
555 return (
556 <Box flexDirection="row" key={`hunk-${index}`} backgroundColor={HUNK_BG}>
557 <Text backgroundColor={HUNK_NUM_BG}>{' '.repeat(GUTTER)}</Text>
558 {buttons.flatMap(button => [
559 <Text backgroundColor={HUNK_BG}> </Text>,
560 <Button key={button.key} label={button.label} plain onPress={button.onPress} />,
561 ])}
562 <Text color={MUTED} backgroundColor={HUNK_BG}>
563 {fillWidth(` ${header}`, diffWidth - GUTTER - used)}
564 </Text>
565 </Box>
566 )
567 }
568
569 // index 番目の hunk の前 (hunks.length なら最後の hunk の後) に隠れた行と、その展開ボタン
570 // 隠れた行が残っていれば、上 (前の hunk の続き) と下 (次の hunk の手前) から EXPAND_STEP 行ずつ開ける
571 // すべて開いたら、間のヘッダ行も消して前後をつなげる
572 const gapBlock = (shown: DiffReviewFile, index: number, header: string | null) => {
573 const gap = gapsOf(shown).find(g => g.index === index)
574 if (gap === undefined || shown.source == null) return header === null ? [] : [hunkHeader(index, header, [])]
575 const key = expansionKey(shown.path, index)
576 const state = opened[key] ?? { top: 0, bottom: 0 }
577 const revealed = revealGap(gap, shown.source, state)
578 const size = revealed.top.length + revealed.bottom.length + revealed.hidden
579 const isFirst = index === 0
580 const isLast = index === shown.hunks.length
581 const buttons =
582 revealed.hidden === 0
583 ? []
584 : revealed.hidden <= EXPAND_STEP
585 ? [{ key: `expand-${index}-all`, label: `${revealed.hidden}行を展開`, onPress: () => expand(key, size, 0) }]
586 : [
587 ...(isFirst ? [] : [{ key: `expand-${index}-down`, label: `下へ${EXPAND_STEP}行`, onPress: () => expand(key, state.top + EXPAND_STEP, state.bottom) }]),
588 ...(isLast ? [] : [{ key: `expand-${index}-up`, label: `上へ${EXPAND_STEP}行`, onPress: () => expand(key, state.top, state.bottom + EXPAND_STEP) }]),
589 { key: `expand-${index}-all`, label: `すべて (${revealed.hidden}行)`, onPress: () => expand(key, size, 0) },
590 ]
591 const isHeaderShown = header !== null ? size === 0 || revealed.hidden > 0 : revealed.hidden > 0
592 return [
593 ...rowsOf(shown, revealed.top, `gap-${index}-top`),
594 ...(isHeaderShown ? [hunkHeader(index, header ?? '', buttons)] : []),
595 ...rowsOf(shown, revealed.bottom, `gap-${index}-bottom`),
596 ]
597 }
598
599 const fileView = (shown: DiffReviewFile) => {
600 if (shown.isBinary) return <Text dimColor>バイナリファイルのため表示しません</Text>
601 if (shown.hunks.length === 0) return <Text dimColor>表示できる差分がありません (モード変更やリネームのみ)</Text>
602 return (
603 <Box flexDirection="column">
604 {shown.hunks.flatMap((hunk, hunkIndex) => [
605 ...gapBlock(shown, hunkIndex, hunk.header),
606 ...rowsOf(shown, hunk.rows, `${hunkIndex}`),
607 ])}
608 {gapBlock(shown, shown.hunks.length, null)}
609 {shown.isTruncated && <Text dimColor>... {`以降は省略しています`}</Text>}
610 </Box>
611 )
612 }
613
614 const submitComment = async (value: string) => {
615 if (file === null) {
616 $.ui.toast('diff-review: ファイルを選んでからコメントしてください')
617 return
618 }
619 const parsed = parseCommentInput(value)
620 if (parsed === null) {
621 $.ui.toast('diff-review: "R12 本文" の形で入力してください (L=変更前, R=変更後)')
622 return
623 }
624 const note: DiffReviewComment = { id: newId(), path: file.path, ...parsed }
625 await update($, comments, list => [...list, note])
626 }
627
628 const selectFile = async (value: string) => {
629 await update($, selected, () => value)
630 await update($, target, () => null)
631 }
632
633 const toggleDir = (dir: string) =>
634 update($, collapsed, list => (list.includes(dir) ? list.filter(d => d !== dir) : [...list, dir]))
635
636 const sidebar = () => {
637 // 枠線の左右2セルを除いた幅
638 const inner = sideWidth - 2
639 return (
640 <Box flexDirection="column" width={sideWidth} borderStyle="round" borderDimColor>
641 <Text bold>
642 {fillWidth(sidebarTitle, inner)}
643 </Text>
644 {tree.map(({ row, label, suffix, added, removed, mark, gen }) => {
645 if (row.kind === 'dir') {
646 return (
647 <Button
648 key={`dir-${row.path}`}
649 label={fillWidth(label, inner)}
650 plain
651 dimColor
652 onPress={() => toggleDir(row.path)}
653 />
654 )
655 }
656 const isCurrent = row.path === path
657 const bg = isCurrent ? CURRENT_FILE_BG : undefined
658 const nameWidth = inner - displayWidth(suffix)
659 return (
660 <Box flexDirection="row" key={`file-row-${row.path}`} backgroundColor={bg}>
661 <Button
662 key={`file-${row.path}`}
663 label={fillWidth(label, Math.max(4, nameWidth))}
664 plain
665 dimColor={!isCurrent}
666 onPress={() => selectFile(row.path)}
667 />
668 <Text color="green" backgroundColor={bg}>
669 {added}
670 </Text>
671 <Text color={row.file.isUntracked ? 'cyan' : 'red'} backgroundColor={bg}>
672 {removed}
673 </Text>
674 {mark !== '' && (
675 <Text color="yellow" backgroundColor={bg}>
676 {mark}
677 </Text>
678 )}
679 {gen !== '' && (
680 <Text dimColor backgroundColor={bg}>
681 {gen}
682 </Text>
683 )}
684 </Box>
685 )
686 })}
687 </Box>
688 )
689 }
690
691 const diffColumn = (shown: DiffReviewFile) => (
692 <Box flexDirection="column" width={diffWidth}>
693 <Text bold wrap="truncate-end">
694 {shown.path}
695 </Text>
696 {aimed !== null && !aimed.isEditing && aimed.path === shown.path ? (
697 <Box flexDirection="row" gap={1}>
698 <Text color="cyan">
699 {aimed.side}
700 {aimed.start} を起点に選択中。同じ行をもう一度押すと1行、同じ側の別の行を押すと範囲でコメントできます
701 </Text>
702 <Button key="anchor-cancel" label="選択解除" plain dimColor onPress={() => update($, target, () => null)} />
703 </Box>
704 ) : (
705 <Text dimColor>行番号を2回押すとコメントできます (同じ行を2回で1行、別の行で範囲)</Text>
706 )}
707 {shape === 'split' && (
708 <Box flexDirection="row">
709 <Box width={half}>
710 <Text bold wrap="truncate-end">
711 {shown.oldPath === shown.path ? '変更前' : `変更前 (${shown.oldPath})`}
712 </Text>
713 </Box>
714 <Text dimColor>│</Text>
715 <Box width={half}>
716 <Text bold>変更後</Text>
717 </Box>
718 </Box>
719 )}
720 {shape === 'unified' && shown.oldPath !== shown.path && <Text dimColor>{`${shown.oldPath} から名前変更`}</Text>}
721 {fileView(shown)}
722 </Box>
723 )
724
725 const send = async () => {
726 if (notes.length === 0) {
727 $.ui.toast('diff-review: コメントがありません')
728 return
729 }
730 await $.prompt.fill({ text: formatComments(notes, snap?.baseLabel ?? '-'), mode: 'replace' })
731 await update($, comments, () => [])
732 $.ui.toast('diff-review: コメントをプロンプトに入れました。Enter で送信できます')
733 }
734
735 return (
736 <Box flexDirection="column">
737 <Box flexDirection="row" gap={1}>
738 <Button key="mode-branch" label="branch" hotkey="b" variant={current === 'branch' ? 'primary' : undefined} onPress={setMode('branch')} />
739 <Button
740 key="mode-uncommitted"
741 label="uncommitted"
742 hotkey="u"
743 variant={current === 'uncommitted' ? 'primary' : undefined}
744 onPress={setMode('uncommitted')}
745 />
746 <Button key="refresh" label="更新" hotkey="r" onPress={() => refresh($)} />
747 <Text dimColor>|</Text>
748 <Button key="view-split" label="split" hotkey="p" variant={shape === 'split' ? 'primary' : undefined} onPress={() => setView($, 'split')} />
749 <Button key="view-unified" label="unified" hotkey="n" variant={shape === 'unified' ? 'primary' : undefined} onPress={() => setView($, 'unified')} />
750 <Button key="wrap" label="折り返し" hotkey="w" variant={isWrapped ? 'primary' : undefined} onPress={() => toggleWrap($)} />
751 {generatedCount > 0 && <Text dimColor>|</Text>}
752 {generatedCount > 0 && (
753 <Button
754 key="generated"
755 label={isGeneratedShown ? `生成ファイルを隠す (${generatedCount})` : `生成ファイルを表示 (${generatedCount})`}
756 hotkey="g"
757 variant={isGeneratedShown ? 'primary' : undefined}
758 onPress={() => toggleGenerated($)}
759 />
760 )}
761 </Box>
762 {current === 'branch' && (
763 <Select
764 key="base"
765 label="比較元"
766 value={chosenBase ?? DEFAULT_BASE}
767 options={limitOptions(
768 [
769 { value: DEFAULT_BASE, label: '既定ブランチ (origin/HEAD)' },
770 // コマンドで一覧にないものを指定したときも、選んだものが見えるように足す
771 ...(chosenBase !== null && !(snap?.branches ?? []).includes(chosenBase) ? [chosenBase] : []).map(b => ({ value: b, label: b })),
772 ...(snap?.branches ?? []).map(b => ({ value: b, label: b })),
773 ],
774 chosenBase ?? DEFAULT_BASE,
775 )}
776 onSelect={async value => {
777 await setBaseBranch($, value === DEFAULT_BASE ? null : value)
778 await refresh($)
779 }}
780 />
781 )}
782 <Text dimColor wrap="truncate-end">
783 base: {snap?.baseLabel ?? '読み込み中...'}
784 </Text>
785 {snap?.error != null && <Text color="red">{snap.error}</Text>}
786 {snap !== null && snap.files.length === 0 && snap.error === null && <Text dimColor>差分はありません</Text>}
787 {snap !== null && snap.files.length > 0 && files.length === 0 && (
788 <Text dimColor>自動生成ファイルの差分だけです (g で表示)</Text>
789 )}
790 {files.length > 0 && hasSidebar && (
791 <Box flexDirection="row" gap={1}>
792 {sidebar()}
793 {file !== null && diffColumn(file)}
794 </Box>
795 )}
796 {files.length > 0 && !hasSidebar && (
797 <Box flexDirection="column">
798 <Select
799 key="file"
800 label="file"
801 value={path ?? undefined}
802 options={limitOptions(
803 files.map(f => ({
804 value: f.path,
805 label: `${f.path} +${f.added} -${f.removed}${f.isUntracked ? ' (untracked)' : ''}${f.isGenerated === true ? ' (generated)' : ''}`,
806 })),
807 path,
808 )}
809 onSelect={selectFile}
810 />
811 {file !== null && diffColumn(file)}
812 </Box>
813 )}
814 <Box marginTop={1} flexDirection="column">
815 <Input
816 key="comment"
817 label="行指定"
818 placeholder="R12 本文 / L3-5 本文 (L=変更前の行, R=変更後の行, 省略時 R)"
819 submitLabel="追加"
820 onSubmit={submitComment}
821 />
822 {notes.map(note => (
823 <Box flexDirection="row" gap={1} key={`note-${note.id}`}>
824 <Button key={`del-${note.id}`} label="x" plain dimColor onPress={() => update($, comments, list => list.filter(n => n.id !== note.id))} />
825 <Text wrap="truncate-end">
826 {note.path}:{note.side}
827 {lineLabel(note.start, note.end)} {note.body}
828 </Text>
829 </Box>
830 ))}
831 <Box flexDirection="row" gap={1}>
832 <Button key="send" label={`Claude に渡す (${notes.length})`} hotkey="s" variant="primary" onPress={send} />
833 <Button key="clear" label="コメントを消す" onPress={() => update($, comments, () => [])} />
834 </Box>
835 </Box>
836 </Box>
837 )
838 })
839}
840hooks/diff.ts 536 lines1import type {
2 DiffReviewCell,
3 DiffReviewComment,
4 DiffReviewExpansion,
5 DiffReviewFile,
6 DiffReviewHunk,
7 DiffReviewRow,
8 DiffReviewSide,
9 DiffReviewRange,
10 DiffReviewTarget,
11} from '../types'
12
13export const MAX_ROWS_PER_FILE = 400
14
15const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/
16
17// 端末で幅が狂う制御文字を落とし、タブは空白2つに置き換える
18export const sanitize = (text: string): string =>
19 text.replace(/\t/g, ' ').replace(/[\u0000-\u001f\u007f]/g, '')
20
21// 全角文字 (CJK、全角記号、絵文字) を2セルとして数える
22const cellWidth = (char: string): number => {
23 const code = char.codePointAt(0) ?? 0
24 return (code >= 0x1100 && code <= 0x115f) ||
25 (code >= 0x2e80 && code <= 0xa4cf) ||
26 (code >= 0xac00 && code <= 0xd7a3) ||
27 (code >= 0xf900 && code <= 0xfaff) ||
28 (code >= 0xfe30 && code <= 0xfe4f) ||
29 (code >= 0xff00 && code <= 0xff60) ||
30 (code >= 0xffe0 && code <= 0xffe6) ||
31 (code >= 0x1f300 && code <= 0x1faff) ||
32 (code >= 0x20000 && code <= 0x3fffd)
33 ? 2
34 : 1
35}
36
37// 端末の切り詰めに任せると行が2段になることがあるため、表示幅で先に切っておく
38export const displayWidth = (text: string): number => {
39 let width = 0
40 for (const char of text) width += cellWidth(char)
41 return width
42}
43
44export const fitWidth = (text: string, columns: number): string => {
45 if (columns <= 0) return ''
46 if (displayWidth(text) <= columns) return text
47 // 末尾1セルを切れた印 ">" にあて、全角で1セル余ったら空白で詰める
48 const budget = columns - 1
49 let used = 0
50 let out = ''
51 for (const char of text) {
52 const width = cellWidth(char)
53 if (used + width > budget) break
54 out += char
55 used += width
56 }
57 return `${out}${' '.repeat(budget - used)}>`
58}
59
60// 背景色をセルの端まで塗るため、表示幅いっぱいまで空白で埋める
61export const fillWidth = (text: string, columns: number): string => {
62 const fitted = fitWidth(text, columns)
63 return `${fitted}${' '.repeat(Math.max(0, columns - displayWidth(fitted)))}`
64}
65
66// 表示幅 columns ごとに折り返し、各行を空白で埋める。空の行も1行として返す
67export const wrapWidth = (text: string, columns: number): string[] => {
68 if (columns <= 0) return ['']
69 const lines: string[] = []
70 let out = ''
71 let used = 0
72 for (const char of text) {
73 const width = cellWidth(char)
74 if (used + width > columns) {
75 lines.push(`${out}${' '.repeat(columns - used)}`)
76 out = ''
77 used = 0
78 }
79 out += char
80 used += width
81 }
82 lines.push(`${out}${' '.repeat(Math.max(0, columns - used))}`)
83 return lines
84}
85
86const stripPrefix =(path: string): string => path.replace(/^[ab]\//, '')
87
88const newFile = (path: string): DiffReviewFile => ({
89 path,
90 oldPath: path,
91 added: 0,
92 removed: 0,
93 isBinary: false,
94 isUntracked: false,
95 isTruncated: false,
96 isGenerated: false,
97 hunks: [],
98 source: null,
99})
100
101// ファイルの中身を行に分ける。末尾の改行は最終行の後ろの空行として数えない
102export const splitLines = (content: string): string[] =>
103 content.endsWith('\n') ? content.slice(0, -1).split('\n') : content.split('\n')
104
105const countRows = (file: DiffReviewFile): number =>
106 file.hunks.reduce((sum, hunk) => sum + hunk.rows.length, 0)
107
108// 削除行と追加行を上から順に左右へ対にし、余った側は空セルで埋める
109const pairRows = (dels: DiffReviewCell[], adds: DiffReviewCell[]): DiffReviewRow[] => {
110 const rows: DiffReviewRow[] = []
111 for (let i = 0; i < Math.max(dels.length, adds.length); i++) {
112 rows.push({ left: dels[i] ?? null, right: adds[i] ?? null })
113 }
114 return rows
115}
116
117export const parseUnifiedDiff = (text: string): DiffReviewFile[] => {
118 const files: DiffReviewFile[] = []
119 let file: DiffReviewFile | null = null
120 let hunk: DiffReviewHunk | null = null
121 let oldNo = 0
122 let newNo = 0
123 let dels: DiffReviewCell[] = []
124 let adds: DiffReviewCell[] = []
125
126 const flush = () => {
127 if (hunk !== null && (dels.length > 0 || adds.length > 0)) {
128 hunk.rows.push(...pairRows(dels, adds))
129 }
130 dels = []
131 adds = []
132 }
133
134 const push = (row: DiffReviewRow) => {
135 if (hunk !== null) hunk.rows.push(row)
136 }
137
138 for (const line of text.split('\n')) {
139 if (line.startsWith('diff --git ')) {
140 flush()
141 hunk = null
142 const match = /^diff --git a\/(.+) b\/(.+)$/.exec(line)
143 file = newFile(match?.[2] ?? line.slice('diff --git '.length))
144 if (match?.[1] !== undefined) file.oldPath = match[1]
145 files.push(file)
146 continue
147 }
148 if (file === null) continue
149
150 // hunk の中では先頭1文字が行の種類を表す。"--- " で始まる削除行もここで扱う
151 if (hunk !== null) {
152 const body = sanitize(line.slice(1))
153 if (line[0] === '-') {
154 dels.push({ kind: 'del', no: oldNo++, text: body })
155 file.removed++
156 continue
157 }
158 if (line[0] === '+') {
159 adds.push({ kind: 'add', no: newNo++, text: body })
160 file.added++
161 continue
162 }
163 if (line[0] === ' ') {
164 flush()
165 push({
166 left: { kind: 'ctx', no: oldNo++, text: body },
167 right: { kind: 'ctx', no: newNo++, text: body },
168 })
169 continue
170 }
171 // "\ No newline at end of file" は表示しない
172 if (line[0] === '\\') continue
173 }
174
175 const header = HUNK_HEADER.exec(line)
176 if (header !== null) {
177 flush()
178 hunk = null
179 if (countRows(file) >= MAX_ROWS_PER_FILE) {
180 file.isTruncated = true
181 continue
182 }
183 oldNo = Number(header[1])
184 newNo = Number(header[2])
185 hunk = { header: sanitize(line), rows: [] }
186 file.hunks.push(hunk)
187 continue
188 }
189 if (line.startsWith('--- ')) {
190 const path = line.slice(4)
191 if (path !== '/dev/null') file.oldPath = stripPrefix(path)
192 continue
193 }
194 if (line.startsWith('+++ ')) {
195 const path = line.slice(4)
196 file.path = path === '/dev/null' ? file.oldPath : stripPrefix(path)
197 continue
198 }
199 if (line.startsWith('Binary files ')) file.isBinary = true
200 }
201 flush()
202
203 for (const one of files) {
204 let left = MAX_ROWS_PER_FILE
205 for (const h of one.hunks) {
206 if (h.rows.length > left) {
207 h.rows = h.rows.slice(0, Math.max(0, left))
208 one.isTruncated = true
209 }
210 left -= h.rows.length
211 }
212 one.hunks = one.hunks.filter(h => h.rows.length > 0)
213 }
214 return files
215}
216
217// untracked なファイルは git diff に出ないので、全行追加として組み立てる
218export const untrackedFile = (path: string, content: string): DiffReviewFile => {
219 const lines = splitLines(content)
220 const file = newFile(path)
221 file.isUntracked = true
222 file.added = lines.length
223 const shown = lines.slice(0, MAX_ROWS_PER_FILE)
224 file.isTruncated = lines.length > shown.length
225 file.hunks = [
226 {
227 header: `@@ -0,0 +1,${lines.length} @@ (untracked)`,
228 rows: shown.map((text, i) => ({ left: null, right: { kind: 'add', no: i + 1, text: sanitize(text) } })),
229 },
230 ]
231 return file
232}
233
234const GENERATED_NAMES = new Set([
235 'package-lock.json',
236 'npm-shrinkwrap.json',
237 'pnpm-lock.yaml',
238 'yarn.lock',
239 'bun.lockb',
240 'bun.lock',
241 'Gemfile.lock',
242 'Pipfile.lock',
243 'poetry.lock',
244 'uv.lock',
245 'pdm.lock',
246 'composer.lock',
247 'Cargo.lock',
248 'go.sum',
249 'pubspec.lock',
250 'flake.lock',
251 'Package.resolved',
252 'packages.lock.json',
253 'gradle.lockfile',
254 '.terraform.lock.hcl',
255])
256
257const GENERATED_SUFFIXES = [
258 '.min.js',
259 '.min.css',
260 '.map',
261 '.pb.go',
262 '.pb.cc',
263 '.pb.h',
264 '_pb2.py',
265 '_pb2_grpc.py',
266 '_pb2.pyi',
267 '_string.go',
268 '.g.dart',
269 '.freezed.dart',
270 '.g.cs',
271 '.designer.cs',
272]
273
274const GENERATED_PREFIXES = ['vendor/', 'node_modules/']
275
276const GENERATED_DIRS = ['__generated__', 'generated']
277
278const GENERATED_HEADERS = [/@generated\b/, /\bDO NOT EDIT\b/i, /\bauto-?generated\b/i, /<auto-generated/i]
279
280export const HEADER_LINES = 20
281
282export const isGeneratedPath = (path: string): boolean => {
283 const parts = path.split('/')
284 const name = parts.at(-1) ?? path
285 return (
286 GENERATED_NAMES.has(name) ||
287 GENERATED_SUFFIXES.some(suffix => name.endsWith(suffix)) ||
288 /\.(generated|gen)\.\w+$/.test(name) ||
289 GENERATED_PREFIXES.some(prefix => path.startsWith(prefix)) ||
290 parts.slice(0, -1).some(dir => GENERATED_DIRS.includes(dir))
291 )
292}
293
294export const isGeneratedHeader = (path: string, lines: readonly string[]): boolean => {
295 if (/\.(md|markdown)$/i.test(path)) return false
296 const header = lines.slice(0, HEADER_LINES).join('\n')
297 return GENERATED_HEADERS.some(pattern => pattern.test(header))
298}
299
300export const headerOf = (file: DiffReviewFile): string[] | null => {
301 const first = file.hunks[0]
302 const header = first === undefined ? null : HUNK_HEADER.exec(first.header)
303 if (first === undefined || header === null) return null
304 if (header[2] === '1') return first.rows.flatMap(row => (row.right === null ? [] : [row.right.text])).slice(0, HEADER_LINES)
305 if (header[1] === '1' && header[2] === '0') {
306 return first.rows.flatMap(row => (row.left === null ? [] : [row.left.text])).slice(0, HEADER_LINES)
307 }
308 return null
309}
310
311export const parseCheckAttr = (stdout: string): Set<string> => {
312 const parts = stdout.split('\u0000')
313 const paths = new Set<string>()
314 for (let i = 0; i + 2 < parts.length; i += 3) {
315 const value = parts[i + 2]
316 if (parts[i + 1] === 'linguist-generated' && (value === 'true' || value === 'set')) paths.add(parts[i] ?? '')
317 }
318 return paths
319}
320
321// Select の options は 64 件までで、超えるとペイン全体が描かれない
322export const MAX_SELECT_OPTIONS = 64
323
324// 先頭から上限まで残す。選択中の値が溢れたときは最後の1件と入れ替えて残す
325export const limitOptions = <T extends { value: string }>(options: readonly T[], keep: string | null | undefined): T[] => {
326 if (options.length <= MAX_SELECT_OPTIONS) return [...options]
327 const head = options.slice(0, MAX_SELECT_OPTIONS)
328 if (keep == null || head.some(o => o.value === keep)) return head
329 const kept = options.find(o => o.value === keep)
330 return kept === undefined ? head : [...head.slice(0, -1), kept]
331}
332
333// git for-each-ref --format=%(refname) の出力を、比較元に選べるブランチ名 (main, origin/feature 等) にする
334// origin/HEAD は既定ブランチを指すだけの別名なので除く
335export const parseBranches = (stdout: string): string[] => {
336 const names = stdout
337 .split('\n')
338 .map(line => line.trim())
339 .filter(ref => ref !== '' && !/^refs\/remotes\/[^/]+\/HEAD$/.test(ref))
340 .map(ref => ref.replace(/^refs\/(heads|remotes)\//, ''))
341 return [...new Set(names)]
342}
343
344export type ParsedComment ={ side: DiffReviewSide; start: number; end: number; body: string }
345
346// "R12 本文" / "L3-5 本文" / "12 本文" (省略時は変更後の側) を読む
347export const parseCommentInput = (value: string): ParsedComment | null => {
348 const match = /^\s*([LRlr])?(\d+)(?:-(\d+))?\s+([\s\S]+?)\s*$/.exec(value)
349 if (match === null) return null
350 const side: DiffReviewSide = match[1]?.toUpperCase() === 'L' ? 'L' : 'R'
351 const a = Number(match[2])
352 const b = match[3] === undefined ? a : Number(match[3])
353 return { side, start: Math.min(a, b), end: Math.max(a, b), body: match[4] ?? '' }
354}
355
356export const commentKey = (path: string, side: DiffReviewSide, line: number): string =>
357 `${path}\u0000${side}${line}`
358
359export const lineLabel = (start: number, end: number): string => (start === end ? `${start}` : `${start}-${end}`)
360
361export const isInRange = (range: DiffReviewRange | null, path: string, side: DiffReviewSide, line: number): boolean =>
362 range !== null && range.path === path && range.side === side && range.start <= line && line <= range.end
363
364// 行番号を押したときの選択の移り方。修飾キーは届かないので、2回のクリックで範囲を決める
365// 起点なし / 入力欄が開いている / 別の側や別のファイル -> その行を起点にする (入力欄はまだ開かない)
366// 起点ありで同じ行 -> その1行で入力欄を開く
367// 起点ありで同じファイル同じ側の別の行 -> 起点からその行までの範囲で入力欄を開く
368export const nextTarget = (
369 current: DiffReviewTarget | null,
370 path: string,
371 side: DiffReviewSide,
372 line: number,
373): DiffReviewTarget => {
374 const anchor: DiffReviewTarget = { path, side, start: line, end: line, isEditing: false }
375 if (current === null || current.isEditing || current.path !== path || current.side !== side) return anchor
376 return { path, side, start: Math.min(current.start, line), end: Math.max(current.start, line), isEditing: true }
377}
378
379export const formatComments = (comments: readonly DiffReviewComment[], baseLabel: string): string => {
380 const lines = comments.map(c => {
381 const where = `${c.path}:${lineLabel(c.start, c.end)}${c.side === 'L' ? ' (変更前)' : ''}`
382 return `- ${where}: ${c.body}`
383 })
384 return [`差分 (base: ${baseLabel}) へのレビューコメントです。それぞれ対応してください。`, '', ...lines].join('\n')
385}
386
387export type TreeRow =
388 | { kind: 'dir'; path: string; name: string; depth: number; isCollapsed: boolean }
389 | { kind: 'file'; path: string; name: string; depth: number; file: DiffReviewFile }
390
391type TreeNode = { name: string; path: string; dirs: Map<string, TreeNode>; files: DiffReviewFile[] }
392
393const newNode = (name: string, path: string): TreeNode => ({ name, path, dirs: new Map(), files: [] })
394
395// 変更ファイルをディレクトリ構造の行に並べる。子が1つだけのディレクトリは "a/b/" のように1行にまとめる
396export const buildTree = (files: readonly DiffReviewFile[], collapsed: readonly string[]): TreeRow[] => {
397 const root = newNode('', '')
398 for (const file of files) {
399 const parts = file.path.split('/')
400 let node = root
401 for (const part of parts.slice(0, -1)) {
402 const path = node.path === '' ? part : `${node.path}/${part}`
403 const child = node.dirs.get(part) ?? newNode(part, path)
404 node.dirs.set(part, child)
405 node = child
406 }
407 node.files.push(file)
408 }
409
410 const closed = new Set(collapsed)
411 const rows: TreeRow[] = []
412 const walk = (node: TreeNode, depth: number) => {
413 for (const dir of [...node.dirs.values()].sort((a, b) => a.name.localeCompare(b.name))) {
414 let merged = dir
415 let name = dir.name
416 while (merged.files.length === 0 && merged.dirs.size === 1) {
417 const only = [...merged.dirs.values()][0]
418 if (only === undefined) break
419 merged = only
420 name = `${name}/${only.name}`
421 }
422 const isCollapsed = closed.has(merged.path)
423 rows.push({ kind: 'dir', path: merged.path, name: `${name}/`, depth, isCollapsed })
424 if (!isCollapsed) walk(merged, depth + 1)
425 }
426 for (const file of [...node.files].sort((a, b) => a.path.localeCompare(b.path))) {
427 rows.push({ kind: 'file', path: file.path, name: file.path.split('/').pop() ?? file.path, depth, file })
428 }
429 }
430 walk(root, 0)
431 return rows
432}
433
434// 変更ファイル一覧の幅 (枠線込み)。いちばん長い行が切れずに収まる幅にするが、差分側に DIFF_MIN_COLUMNS 列は残す
435export const SIDEBAR_MIN_WIDTH = 22
436export const DIFF_MIN_COLUMNS = 50
437
438export const sidebarWidth = (lines: readonly string[], columns: number): number => {
439 const longest = lines.reduce((max, line) => Math.max(max, displayWidth(line)), 0)
440 const limit = Math.max(SIDEBAR_MIN_WIDTH, Math.min(Math.floor(columns / 2), columns - DIFF_MIN_COLUMNS))
441 return Math.min(limit, Math.max(SIDEBAR_MIN_WIDTH, longest + 2))
442}
443
444export type UnifiedLine = {
445 kind: DiffReviewCell['kind']
446 oldNo: number | null
447 newNo: number | null
448 text: string
449}
450
451// split 用に左右へ対にした行を、unified の並び (変更の塊ごとに削除行をまとめて先、追加行を後) に戻す
452export const toUnified = (hunk: DiffReviewHunk): UnifiedLine[] => {
453 const lines: UnifiedLine[] = []
454 let dels: UnifiedLine[] = []
455 let adds: UnifiedLine[] = []
456 const flush = () => {
457 lines.push(...dels, ...adds)
458 dels = []
459 adds = []
460 }
461 for (const row of hunk.rows) {
462 if (row.left?.kind === 'ctx' && row.right?.kind === 'ctx') {
463 flush()
464 lines.push({ kind: 'ctx', oldNo: row.left.no, newNo: row.right.no, text: row.right.text })
465 continue
466 }
467 if (row.left !== null) dels.push({ kind: 'del', oldNo: row.left.no, newNo: null, text: row.left.text })
468 if (row.right !== null) adds.push({ kind: 'add', oldNo: null, newNo: row.right.no, text: row.right.text })
469 }
470 flush()
471 return lines
472}
473
474// GitHub と同じく、展開ボタン1回で見せる行数
475export const EXPAND_STEP = 20
476
477type HunkSpan = { oldStart: number; oldEnd: number; newStart: number; newEnd: number }
478
479// hunk が占める行の範囲。行数0の側 ("+5,0" 等) はヘッダの行の直後から始まる空の範囲になる
480export const hunkSpan = (hunk: DiffReviewHunk): HunkSpan | null => {
481 const header = HUNK_HEADER.exec(hunk.header)
482 if (header === null) return null
483 const oldCount = hunk.rows.filter(row => row.left !== null).length
484 const newCount = hunk.rows.filter(row => row.right !== null).length
485 const oldStart = Number(header[1]) + (oldCount === 0 ? 1 : 0)
486 const newStart = Number(header[2]) + (newCount === 0 ? 1 : 0)
487 return { oldStart, oldEnd: oldStart + oldCount - 1, newStart, newEnd: newStart + newCount - 1 }
488}
489
490// hunk の前 (index === hunks.length なら最後の hunk の後) に隠れている、変更後の側の行範囲
491// delta は変更前の行番号 - 変更後の行番号。差分のない行なのでこの範囲では一定
492export type DiffReviewGap = { index: number; newStart: number; newEnd: number; delta: number }
493
494export const gapsOf = (file: DiffReviewFile): DiffReviewGap[] => {
495 if (file.source == null || file.isBinary || file.isUntracked) return []
496 const spans = file.hunks.map(hunkSpan)
497 if (spans.length === 0 || spans.some(span => span === null)) return []
498 const known = spans as HunkSpan[]
499 const gaps: DiffReviewGap[] = known.map((span, index) => {
500 const prev = known[index - 1]
501 return {
502 index,
503 newStart: prev === undefined ? 1 : prev.newEnd + 1,
504 newEnd: span.newStart - 1,
505 delta: span.oldStart - span.newStart,
506 }
507 })
508 // 途中で省略したファイルは最後の hunk の後ろが本当の末尾ではないので、末尾の展開は出さない
509 const last = known.at(-1)
510 if (last !== undefined && !file.isTruncated) {
511 gaps.push({ index: known.length, newStart: last.newEnd + 1, newEnd: file.source.length, delta: last.oldEnd - last.newEnd })
512 }
513 return gaps
514}
515
516export const expansionKey = (path: string, index: number): string => `${path}\u0000${index}`
517
518export type RevealedGap = { top: DiffReviewRow[]; bottom: DiffReviewRow[]; hidden: number }
519
520// 隠れた範囲のうち、上 (前の hunk の続き) と下 (次の hunk の手前) から開いた行を差分のない行として返す
521export const revealGap = (gap: DiffReviewGap, source: readonly string[], expansion: DiffReviewExpansion | undefined): RevealedGap => {
522 const size = Math.max(0, gap.newEnd - gap.newStart + 1)
523 const top = Math.min(size, expansion?.top ?? 0)
524 const bottom = Math.min(size - top, expansion?.bottom ?? 0)
525 const row = (newNo: number): DiffReviewRow => {
526 const text = source[newNo - 1] ?? ''
527 return { left: { kind: 'ctx', no: newNo + gap.delta, text }, right: { kind: 'ctx', no: newNo, text } }
528 }
529 const range = (from: number, count: number) => Array.from({ length: count }, (_, i) => row(from + i))
530 return {
531 top: range(gap.newStart, top),
532 bottom: range(gap.newEnd - bottom + 1, bottom),
533 hidden: size - top - bottom,
534 }
535}
536types/index.d.ts 89 lines1export type DiffReviewMode = 'branch' | 'uncommitted'
2
3export type DiffReviewView = 'split' | 'unified'
4
5export type DiffReviewCell = {
6 kind: 'ctx' | 'add' | 'del'
7 no: number
8 text: string
9}
10
11export type DiffReviewRow = {
12 left: DiffReviewCell | null
13 right: DiffReviewCell | null
14}
15
16export type DiffReviewHunk = {
17 header: string
18 rows: DiffReviewRow[]
19}
20
21export type DiffReviewFile = {
22 path: string
23 oldPath: string
24 added: number
25 removed: number
26 isBinary: boolean
27 isUntracked: boolean
28 isTruncated: boolean
29 isGenerated: boolean
30 hunks: DiffReviewHunk[]
31 // 差分のない行を展開するための変更後のファイルの中身。読めないファイルは null
32 source?: string[] | null
33}
34
35export type DiffReviewSnapshot = {
36 baseLabel: string
37 // 比較元に選べるブランチ (新しくコミットされた順)
38 branches?: string[]
39 files: DiffReviewFile[]
40 error: string | null
41}
42
43export type DiffReviewSide = 'L' | 'R'
44
45// start..end の行範囲 (1行なら start === end)
46export type DiffReviewRange = {
47 path: string
48 side: DiffReviewSide
49 start: number
50 end: number
51}
52
53// 行番号を押して選んでいる範囲。1回目のクリックは起点だけで、2回目で範囲が決まり入力欄が開く
54export type DiffReviewTarget = DiffReviewRange & {
55 isEditing: boolean
56}
57
58export type DiffReviewComment = DiffReviewRange & {
59 id: string
60 body: string
61}
62
63// hunk の間に隠れた行のうち、上から開いた行数と下から開いた行数
64export type DiffReviewExpansion = {
65 top: number
66 bottom: number
67}
68
69declare module 'claude-code' {
70 interface PluginState {
71 'diff-review': {
72 mode: DiffReviewMode
73 snapshot: DiffReviewSnapshot | null
74 selected: string | null
75 comments: DiffReviewComment[]
76 target: DiffReviewTarget | null
77 collapsed: string[]
78 view: DiffReviewView
79 // 長い行をペイン幅で折り返すか
80 isWrapped: boolean
81 showGenerated: boolean
82 // expansionKey(path, hunk の番号) ごとの展開状態
83 expanded: Record<string, DiffReviewExpansion>
84 // branch モードの比較元。null なら origin の既定ブランチ
85 baseBranch: string | null
86 }
87 }
88}
89