Opens a pane showing the full diff of each file changed on this branch vs its base

My Claude Code mods, shared as a plugin marketplace. Mods need Claude Code v2.1.287 or later.
In a Claude Code session:
/plugin marketplace add DiegoHeer/claude-mods
/plugin install review-diff@claude-mods
Update later with /plugin marketplace update claude-mods.
Upgrading from branch-diff: it was renamed, so the update won't carry it over. Run /plugin uninstall branch-diff@claude-mods, then /plugin install review-diff@claude-mods.
/review-diff opens a pane with every file changed on this branch compared to its base, committed, uncommitted and untracked work included, drawn like a pull request's "Files changed" view.
The base is git config review-diff.base if set, else origin/HEAD, else the first of origin/main, origin/master, main, master that exists. To pin it for a repo: git config review-diff.base develop.
Worktrees: the pane follows the worktree Claude last worked in (the one it entered with EnterWorktree, or the one holding the last file it edited), else the session's. When the repo has more than one worktree, a worktree picker in the header pins the pane to any of them; pick follow Claude to go back to following. Review comments stay with the worktree they were written in.
f switches between whole files and changed parts onlyr refreshes; the pane also refreshes on its own after Claude edits files or runs commandsw steps to the next worktree, then back to follow Claude (shown with two or more worktrees)● uncommitted tag● new, untracked tag, up to 50 of them (files over 1 MB are listed as binary, not drawn)Review comments (terminal and desktop, needs the mouse):
✎ marks its lines)s sends all drafts to Claude as one review messageclaude plugin validate ./review-diff
claude plugin test ./review-diff
claude --plugin-dir ./review-diffhooks/register.js 230 lines1// /review-diff: a pane with every file changed on this branch vs its base,
2// stacked like a pull request's files, refreshed as Claude edits. Lines picked
3// with the mouse take review comments, kept as drafts until sent to Claude.
4
5import { atom, read, update } from 'claude-code'
6import { CHANGED_PARTS, WHOLE_FILE, findWorktree, listWorktrees, loadBranchDiff } from './git.js'
7import { anchorOf, fileRows, reviewMessage, rowRange } from './rows.js'
8import { drawPane } from './view.js'
9
10const PANE = 'review-diff'
11const REFRESH_DELAY_MS = 300 // lets a burst of edits settle into one refresh
12const EDITING_TOOLS = ['Edit', 'Write', 'NotebookEdit', 'Bash', 'EnterWorktree', 'ExitWorktree']
13const MOUSE_SURFACES = ['terminal', 'desktop'] // the surfaces that draw a Client
14const TOUCHED_KEPT = 10 // enough to look past a few edits outside every worktree
15
16let isWholeFile = true
17let diff = { files: [] }
18let isOpen = false
19let pendingRefresh = null
20let refreshRun = 0
21
22let worktrees = []
23let target // the worktree shown; undefined when git lists none
24let pickedPath = null // the worktree picked in the pane; null follows Claude
25let touchedPaths = [] // the files and worktrees Claude last worked in, newest first
26let isPickerOpen = false
27
28const drafts = atom({ plugin: 'review-diff', key: 'drafts' }, [])
29let selection = null // rows being picked: { path, anchorRow, from, to }
30let pending = null // the picked lines awaiting their comment, by line number
31let typedComment = '' // what the open comment box holds so far
32let isSending = false
33
34// A draft shows, and is sent, only in the worktree it was written in; one whose
35// worktree git no longer lists shows everywhere, so it is never lost from sight.
36const isHere = (draft) => draft.worktree === target?.path || !worktrees.some((w) => w.path === draft.worktree)
37const readHereDrafts = async ($) => (await read($, drafts)).filter(isHere)
38
39const rowsOf = (path) => {
40 const file = diff.files?.find((f) => f.path === path)
41 return file ? fileRows(file) : null
42}
43
44// A shift-click grows the selection or comment box already open in the same file.
45function openAnchorRow(path, rows) {
46 if (selection?.path === path) return selection.anchorRow
47 if (pending?.path === path) return rowRange(rows, pending)?.from
48 return undefined
49}
50
51function onGesture({ type, path, anchorRow, row, shift }) {
52 const rows = rowsOf(path)
53 if (!rows) return
54 const anchor = (shift ? openAnchorRow(path, rows) : undefined) ?? anchorRow
55 // The open box stays until release: dropping it mid-gesture would re-cut the blocks
56 // and unmount the one holding the pointer.
57 if (type === 'release') {
58 forgetComment()
59 pending = anchorOf(path, rows, anchor, row)
60 selection = null
61 } else {
62 selection = { path, anchorRow: anchor, from: Math.min(anchor, row), to: Math.max(anchor, row) }
63 }
64}
65
66function forgetComment() {
67 pending = null
68 typedComment = ''
69}
70
71async function saveComment($, text, worktree = target?.path) {
72 const anchor = pending
73 // Forgotten before the await, so a box opened meanwhile is not closed by this save.
74 forgetComment()
75 const comment = text.trim()
76 if (comment && anchor) {
77 await update($, drafts, (list) => [...list, { ...anchor, worktree, id: Math.max(0, ...list.map((d) => d.id)) + 1, text: comment }])
78 }
79 $.ui.invalidate('ui.render')
80}
81
82// Sent as the person's own words: the comments are theirs, not the plugin's.
83// Drafts leave only once sent, and only those sent; a half-typed comment goes along.
84async function sendReview($) {
85 if (isSending) return
86 isSending = true
87 try {
88 await saveComment($, typedComment)
89 const list = await readHereDrafts($)
90 if (list.length === 0) return
91 await $.prompt.submit({ text: reviewMessage(diff.base, list, target?.path), asUser: true })
92 const sent = new Set(list.map((d) => d.id))
93 await update($, drafts, (current) => current.filter((d) => !sent.has(d.id)))
94 } finally {
95 isSending = false
96 $.ui.invalidate('ui.render')
97 }
98}
99
100// Run git without ever throwing: a failed start or timeout becomes an exit code.
101async function runGit($, args, cwd) {
102 try {
103 return await $.process.run(['git', ...args], cwd ? { cwd } : undefined)
104 } catch (e) {
105 return { exitCode: 1, stdout: '', stderr: String(e) }
106 }
107}
108
109// The picked worktree, else the one Claude last worked in, else the session's.
110async function chooseTarget($, git) {
111 const picked = worktrees.find((w) => w.path === pickedPath)
112 if (picked) return picked
113 for (const path of touchedPaths) {
114 const followed = await findWorktree(git, worktrees, path)
115 if (followed) return followed
116 }
117 const sessionDir = await $.session.cwd()
118 return findWorktree(git, worktrees, sessionDir, sessionDir)
119}
120
121// Where a tool call worked: the worktree EnterWorktree moved to, or the file an edit wrote.
122const touchedBy = (e, result) => (e.tool === 'EnterWorktree' ? result?.result?.worktreePath : (e.file_path ?? e.notebook_path))
123
124function rememberTouch(e, result) {
125 if (e.tool === 'ExitWorktree') touchedPaths = []
126 const path = touchedBy(e, result)
127 if (path) touchedPaths = [path, ...touchedPaths.filter((p) => p !== path)].slice(0, TOUCHED_KEPT)
128}
129
130async function refresh($) {
131 const run = ++refreshRun
132 const git = (args) => runGit($, args)
133 worktrees = (await listWorktrees(git)) ?? worktrees
134 if (!worktrees.some((w) => w.path === pickedPath)) pickedPath = null
135 if (worktrees.length < 2) isPickerOpen = false
136 const shown = await chooseTarget($, git)
137 const loaded = await loadBranchDiff((args) => runGit($, args, shown?.path), isWholeFile ? WHOLE_FILE : CHANGED_PARTS)
138 if (run !== refreshRun) return // a newer refresh started meanwhile, and its result wins
139 const left = target
140 target = shown
141 diff = loaded
142 // Picked lines name files of the worktree they were picked in: a half-typed comment stays there as a draft.
143 if (shown?.path !== left?.path) {
144 selection = null
145 await saveComment($, typedComment, left?.path)
146 }
147 $.ui.invalidate('ui.render')
148}
149
150function refreshSoon($) {
151 pendingRefresh?.cancel()
152 pendingRefresh = $.clock.after(REFRESH_DELAY_MS, () => refresh($))
153}
154
155export function register(on) {
156 on('session.start', async ($, e, next) => {
157 await $.command.register({ name: 'review-diff', description: 'Show the full branch diff in a pane' })
158 return next(e)
159 })
160
161 on('command.run', { command: 'review-diff' }, async ($) => {
162 await refresh($)
163 isOpen = true
164 await $.ui.open({ id: PANE, title: 'Review diff', focus: true, closeOnEscape: true })
165 return {}
166 })
167
168 on('ui.close', { id: PANE }, async ($, e, next) => {
169 isOpen = false
170 isPickerOpen = false
171 selection = null
172 forgetComment()
173 return next(e)
174 })
175
176 on('tool.call', { tool: EDITING_TOOLS }, async ($, e, next) => {
177 const result = await next(e)
178 rememberTouch(e, result)
179 if (isOpen) refreshSoon($)
180 return result
181 })
182
183 on('ui.message', { requestId: PANE }, async ($, e) => {
184 onGesture(e.data)
185 $.ui.invalidate('ui.render')
186 return {}
187 })
188
189 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
190 if (e.requestId !== PANE) return next(e)
191 const elements = await $.ui.resolve(e)
192 const hasMouse = MOUSE_SURFACES.includes(e.surface)
193 return drawPane({ ...elements, Client: hasMouse ? elements.Client : undefined }, {
194 diff,
195 isWholeFile,
196 columns: e.props.bodyColumns,
197 review: { selection, pending, drafts: await readHereDrafts($) },
198 worktrees,
199 target,
200 isFollowing: pickedPath === null,
201 isPickerOpen,
202 onToggle: () => {
203 isWholeFile = !isWholeFile
204 return refresh($)
205 },
206 onRefresh: () => refresh($),
207 onTogglePicker: () => {
208 isPickerOpen = !isPickerOpen
209 $.ui.invalidate('ui.render')
210 },
211 onPickWorktree: (path) => {
212 pickedPath = path
213 isPickerOpen = false
214 return refresh($)
215 },
216 onJump: (path) => $.ui.scroll({ in: PANE, to: { key: 'file-' + path }, block: 'start' }),
217 onTypeComment: (text) => {
218 typedComment = text
219 },
220 onSaveComment: (text) => saveComment($, text),
221 onCancelComment: () => saveComment($, ''),
222 onDeleteDraft: async (id) => {
223 await update($, drafts, (list) => list.filter((d) => d.id !== id))
224 $.ui.invalidate('ui.render')
225 },
226 onSend: () => sendReview($),
227 })
228 })
229}
230hooks/git.js 178 lines1// Reads the branch's diff from git: the base branch, the commit the branch
2// split off at, and every changed file with its hunks. `git(args)` runs one
3// git command and resolves to { exitCode, stdout, stderr }, never throwing.
4
5export const WHOLE_FILE = 1000000
6export const CHANGED_PARTS = 3
7export const UNTRACKED_LIMIT = 50
8const UNTRACKED_MAX_SIZE = '1m' // past this, git calls an untracked file binary instead of printing it
9
10const DIFF = ['-c', 'core.quotePath=false', 'diff', '--no-ext-diff', '--no-textconv', '--no-renames']
11
12const BASE_KEYS = ['review-diff.base', 'branch-diff.base'] // the second is the name from before the rename
13
14const exists = async (git, ref) => (await git(['rev-parse', '--verify', '--quiet', ref + '^{commit}'])).exitCode === 0
15
16async function configuredBase(git) {
17 for (const key of BASE_KEYS) {
18 const base = (await git(['config', '--get', key])).stdout?.trim()
19 if (base) return { key, base }
20 }
21}
22
23// The branch this one forked from: `git config review-diff.base` if set, else the
24// remote's default, else origin/main, origin/master, main, master. Remote branches
25// come first: a local main is often behind, or is the very branch being worked on.
26async function detectBase(git) {
27 const configured = await configuredBase(git)
28 if (configured) return (await exists(git, configured.base)) ? { base: configured.base } : { error: configured.key + ' is set to ' + configured.base + ', which does not exist.' }
29 const remote = await git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
30 const remoteHead = remote.exitCode === 0 ? remote.stdout.trim() : ''
31 // origin/HEAD is never updated by a fetch, so it can name a branch that is gone.
32 const candidates = new Set([remoteHead, 'origin/main', 'origin/master', 'main', 'master'].filter(Boolean))
33 for (const name of candidates) {
34 if (await exists(git, name)) return { base: name }
35 }
36 return { error: 'No base branch found (looked for origin/HEAD, origin/main, origin/master, main, master). Set one with `git config review-diff.base <branch>`.' }
37}
38
39async function findForkPoint(git, base) {
40 const r = await git(['merge-base', base, 'HEAD'])
41 return r.exitCode === 0 ? r.stdout.trim() : null
42}
43
44function parseNumstat(stdout) {
45 return stdout
46 .split('\n')
47 .filter(Boolean)
48 .map((line) => {
49 const [added, removed, ...rest] = line.split('\t')
50 const isBinary = added === '-'
51 return { path: rest.join('\t'), added: isBinary ? 0 : Number(added), removed: isBinary ? 0 : Number(removed), isBinary, hunks: [] }
52 })
53}
54
55// Hunks per file path, from `git diff -p` output. Lines keep their marker.
56function parseHunks(stdout) {
57 const byPath = new Map()
58 let hunks = null
59 let oldPath = null
60 for (const line of stdout.split('\n')) {
61 if (line.startsWith('diff --git ')) {
62 hunks = null
63 } else if (line.startsWith('--- ')) {
64 oldPath = line.slice(6).replace(/\t$/, '')
65 } else if (line.startsWith('+++ ')) {
66 // git ends the name with a tab when it holds a space.
67 const path = (line === '+++ /dev/null' ? oldPath : line.slice(6)).replace(/\t$/, '')
68 hunks = []
69 byPath.set(path, hunks)
70 } else if (hunks && line.startsWith('@@ -')) {
71 const [, oldStart, newStart] = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)/) ?? []
72 if (oldStart) hunks.push({ oldStart: Number(oldStart), newStart: Number(newStart), lines: [] })
73 } else if (hunks?.length && /^[ +-]/.test(line)) {
74 hunks.at(-1).lines.push(line[0] + stripControlChars(line.slice(1)))
75 }
76 }
77 return byPath
78}
79
80// The pane refuses control characters; tabs are the one kind code needs.
81function stripControlChars(text) {
82 return text.replace(/[\p{Cc}\p{Cf}]/gu, (c) => (c === '\t' ? c : ''))
83}
84
85// Paths that differ from the last commit, staged or not. A failed call tags nothing.
86async function listUncommitted(git) {
87 const r = await git([...DIFF, '--name-only', 'HEAD'])
88 return new Set(r.exitCode === 0 ? r.stdout.split('\n').filter(Boolean) : [])
89}
90
91// Files git does not track yet and does not ignore, each diffed against nothing,
92// up to UNTRACKED_LIMIT of them. Paths are from the top of the repo, like `git diff`'s.
93async function loadUntracked(git, contextLines) {
94 const [listed, top] = await Promise.all([
95 git(['ls-files', '-z', '--others', '--exclude-standard', '--full-name', ':/']),
96 git(['rev-parse', '--show-toplevel']),
97 ])
98 // A nested repo is listed as its folder, ending in '/', and has no text to show.
99 const paths = listed.exitCode === 0 ? listed.stdout.split('\0').filter((path) => path && !path.endsWith('/')) : []
100 const topDir = top.stdout?.trim()
101 if (paths.length === 0 || !topDir) return { files: [], hiddenCount: 0 }
102 const shown = paths.slice(0, UNTRACKED_LIMIT)
103 const files = await Promise.all(shown.map((path) => loadUntrackedFile(git, topDir, path, contextLines)))
104 return { files, hiddenCount: paths.length - shown.length }
105}
106
107async function loadUntrackedFile(git, topDir, path, contextLines) {
108 // --no-index exits 1 when the sides differ, which they always do here.
109 const r = await git(['-C', topDir, '-c', 'core.bigFileThreshold=' + UNTRACKED_MAX_SIZE, ...DIFF, '--no-index', '-U' + contextLines, '--', '/dev/null', path])
110 const isBinary = /^Binary files /m.test(r.stdout ?? '')
111 // The diff holds only this file, and its header can quote the name, so it is not looked up by name.
112 const [hunks = []] = isBinary ? [] : parseHunks(r.stdout ?? '').values()
113 const added = hunks.reduce((n, h) => n + h.lines.filter((l) => l[0] === '+').length, 0)
114 return { path, added, removed: 0, isBinary, hunks, isUntracked: true }
115}
116
117// Fills in each changed file's hunks and uncommitted tag, or returns git's error.
118async function addHunks(git, files, forkPoint, contextLines) {
119 if (files.length === 0) return null
120 const [patch, uncommitted] = await Promise.all([git([...DIFF, '-U' + contextLines, forkPoint]), listUncommitted(git)])
121 if (patch.exitCode !== 0) return patch.stderr.trim() || 'git diff failed'
122 const hunksByPath = parseHunks(patch.stdout)
123 for (const file of files) {
124 file.hunks = hunksByPath.get(file.path) ?? []
125 file.isUncommitted = uncommitted.has(file.path)
126 }
127 return null
128}
129
130// Every worktree of the repo, each `{ path, branch }`; branch is undefined on a detached HEAD.
131// Null when git fails, which says nothing about which worktrees exist.
132export async function listWorktrees(git) {
133 const r = await git(['worktree', 'list', '--porcelain'])
134 if (r.exitCode !== 0) return null
135 return r.stdout.split('\n\n').map(parseWorktree).filter((w) => w.path && !w.isBare && !w.isPrunable)
136}
137
138function parseWorktree(block) {
139 const fields = new Map(block.split('\n').filter(Boolean).map((line) => [line.split(' ', 1)[0], line.slice(line.indexOf(' ') + 1)]))
140 return {
141 path: fields.get('worktree'),
142 branch: fields.get('branch')?.replace(/^refs\/heads\//, ''),
143 isBare: fields.has('bare'),
144 isPrunable: fields.has('prunable'), // its folder is gone
145 }
146}
147
148// The deepest worktree holding the path, since worktrees can sit inside the main one.
149function worktreeHolding(worktrees, path) {
150 const slashed = path.replaceAll('\\', '/')
151 const holders = worktrees.filter((w) => slashed === w.path || slashed.startsWith(w.path + '/'))
152 return holders.sort((a, b) => b.path.length - a.path.length)[0]
153}
154
155// The worktree holding a path, by its name, else as git resolves its folder
156// (reached through a symlink, or spelled some other way).
157export async function findWorktree(git, worktrees, path, folder = path.replace(/[\\/][^\\/]*$/, '')) {
158 const named = worktreeHolding(worktrees, path)
159 if (named || worktrees.length === 0 || !folder) return named
160 const r = await git(['-C', folder, 'rev-parse', '--show-toplevel'])
161 return r.exitCode === 0 ? worktrees.find((w) => w.path === r.stdout.trim()) : undefined
162}
163
164// Everything the pane shows, or an error to show instead.
165export async function loadBranchDiff(git, contextLines) {
166 const { base, error } = await detectBase(git)
167 if (!base) return { error }
168 const forkPoint = await findForkPoint(git, base)
169 if (!forkPoint) return { base, error: 'This branch shares no history with ' + base + '.' }
170
171 const stat = await git([...DIFF, '--numstat', forkPoint])
172 if (stat.exitCode !== 0) return { base, error: stat.stderr.trim() || 'git diff failed' }
173 const files = parseNumstat(stat.stdout)
174 const [hunksError, untracked] = await Promise.all([addHunks(git, files, forkPoint, contextLines), loadUntracked(git, contextLines)])
175 if (hunksError) return { base, error: hunksError }
176 return { base, files: [...files, ...untracked.files], untrackedHiddenCount: untracked.hiddenCount }
177}
178hooks/rows.js 156 lines1// A file's diff as numbered rows, cut into drawable blocks, and the anchors and
2// message review comments are made of.
3
4const MAX_BLOCK_CHARS = 8000 // keeps each Code block well under the engine's per-drawing cap
5const MAX_DRAWN_CHARS = 80000 // keeps the whole pane under the engine's per-tree cap
6
7const countOld = (lines) => lines.filter((l) => l[0] !== '+').length
8const countNew = (lines) => lines.filter((l) => l[0] !== '-').length
9
10// The engine numbers lines from the header, so a cut hunk needs one rebuilt from its own lines.
11function hunkSource({ oldStart, newStart, lines }) {
12 return `@@ -${oldStart},${countOld(lines)} +${newStart},${countNew(lines)} @@\n` + lines.join('\n')
13}
14
15// Pieces of a hunk: cut at the size limit and after every row in `cutRows`.
16function splitHunk(hunk, firstRow, cutRows) {
17 const pieces = []
18 let piece = { oldStart: hunk.oldStart, newStart: hunk.newStart, start: firstRow, lines: [] }
19 let size = 0
20 const nextPiece = () => {
21 pieces.push(piece)
22 piece = {
23 oldStart: piece.oldStart + countOld(piece.lines),
24 newStart: piece.newStart + countNew(piece.lines),
25 start: piece.start + piece.lines.length,
26 lines: [],
27 }
28 size = 0
29 }
30 for (const [i, fullLine] of hunk.lines.entries()) {
31 const line = fullLine.slice(0, MAX_BLOCK_CHARS - 100)
32 if (size + line.length + 1 > MAX_BLOCK_CHARS && piece.lines.length) nextPiece()
33 piece.lines.push(line)
34 size += line.length + 1
35 if (cutRows.has(firstRow + i) && i < hunk.lines.length - 1) nextPiece()
36 }
37 pieces.push(piece)
38 return pieces
39}
40
41function fileBlocks(file, cutRows) {
42 let firstRow = 0
43 return file.hunks.flatMap((hunk) => {
44 const pieces = splitHunk(hunk, firstRow, cutRows)
45 firstRow += hunk.lines.length
46 return pieces.map((piece) => ({ source: hunkSource(piece), start: piece.start, count: piece.lines.length }))
47 })
48}
49
50// Which blocks of which files fit in the pane's budget, in order.
51export function planSections(files, cutsByPath) {
52 const sections = []
53 let budget = MAX_DRAWN_CHARS
54 for (const [i, file] of files.entries()) {
55 const section = { file, blocks: [], isCut: false }
56 sections.push(section)
57 for (const block of fileBlocks(file, cutsByPath.get(file.path) ?? new Set())) {
58 if (block.source.length > budget) {
59 section.isCut = true
60 return { sections, hiddenCount: files.length - i - 1 }
61 }
62 budget -= block.source.length
63 section.blocks.push(block)
64 }
65 }
66 return { sections, hiddenCount: 0 }
67}
68
69const KINDS = { ' ': 'ctx', '+': 'add', '-': 'del' }
70
71// Each refresh brings new file objects, so rows are worked out once per diff.
72const rowsCache = new WeakMap()
73
74export function fileRows(file) {
75 if (!rowsCache.has(file)) rowsCache.set(file, numberRows(file))
76 return rowsCache.get(file)
77}
78
79function numberRows(file) {
80 const rows = []
81 for (const hunk of file.hunks) {
82 let oldNo = hunk.oldStart
83 let newNo = hunk.newStart
84 for (const line of hunk.lines) {
85 const kind = KINDS[line[0]] ?? 'ctx'
86 const row = { kind, line }
87 if (kind !== 'add') row.oldNo = oldNo++
88 if (kind !== 'del') row.newNo = newNo++
89 rows.push(row)
90 }
91 }
92 return rows
93}
94
95// A row named by the number it keeps after a refresh: its new line, else its old one.
96const refOf = (row) => (row.newNo !== undefined ? { side: 'new', no: row.newNo } : { side: 'old', no: row.oldNo })
97const isRow = (ref) => (row) => row[ref.side === 'new' ? 'newNo' : 'oldNo'] === ref.no
98
99const range = (numbers) => {
100 const [first, last] = [Math.min(...numbers), Math.max(...numbers)]
101 return first === last ? `L${first}` : `L${first}-${last}`
102}
103
104// Kept lines are named in the new file, removed ones in the old file.
105export function anchorOf(path, rows, from, to) {
106 const picked = rows.slice(Math.min(from, to), Math.max(from, to) + 1)
107 const kept = picked.filter((row) => row.newNo !== undefined).map((row) => row.newNo)
108 const removed = picked.filter((row) => row.kind === 'del').map((row) => row.oldNo)
109 return {
110 path,
111 from: refOf(picked[0]),
112 to: refOf(picked.at(-1)),
113 label: [kept.length && range(kept), removed.length && 'old ' + range(removed)].filter(Boolean).join(', '),
114 snippet: picked.map((row) => row.line).join('\n'),
115 }
116}
117
118export function rowRange(rows, anchor) {
119 const from = rows.findIndex(isRow(anchor.from))
120 const to = rows.findIndex(isRow(anchor.to))
121 return from === -1 || to === -1 ? null : { from, to }
122}
123
124export const PENDING_ID = 'pending' // the open comment box among the notes; drafts have numeric ids
125
126// Where a file's review shows: a mark per row, the rows a block must end after,
127// and the row each note (draft card or comment box) goes under; a note whose lines are gone has none.
128export function placeReview(path, rows, { selection, notes }) {
129 const marks = Array(rows.length).fill(' ')
130 const cuts = new Set()
131 const endRows = new Map()
132 for (const note of notes.filter((n) => n.path === path)) {
133 const found = rowRange(rows, note)
134 if (!found) continue
135 // While new lines are being picked, the open box keeps its place but not its bar.
136 const sign = note.id !== PENDING_ID ? '✎' : selection ? null : '▌'
137 if (sign) marks.fill(sign, found.from, found.to + 1)
138 cuts.add(found.to)
139 endRows.set(note.id, found.to)
140 }
141 if (selection?.path === path) marks.fill('▌', selection.from, selection.to + 1)
142 return { marks: marks.join(''), cuts, endRows }
143}
144
145// Longer than any backtick run in the snippet, so its own fences cannot close this one.
146const fenceFor = (text) => '`'.repeat(Math.max(3, ...(text.match(/`+/g) ?? []).map((run) => run.length + 1)))
147
148export function reviewMessage(base, drafts, worktree) {
149 const items = drafts.map((draft, i) => {
150 const fence = fenceFor(draft.snippet)
151 return `${i + 1}. ${draft.path} ${draft.label}\n${fence}diff\n${draft.snippet}\n${fence}\n${draft.text}`
152 })
153 const where = worktree ? `, in the worktree at ${worktree}` : ''
154 return [`Review comments on this branch (vs ${base})${where}. Please address each one.`, ...items].join('\n\n')
155}
156hooks/view.js 168 lines1// Draws the pane: a header, a list of changed files, then every file's diff
2// stacked, each through the engine's own diff drawing (Code, format 'diff').
3
4import { PENDING_ID, fileRows, placeReview, planSections } from './rows.js'
5
6const plural = (n, word) => n + ' ' + word + (n === 1 ? '' : 's')
7
8const FOLLOW = 'follow' // the picker option for following Claude
9const nameOf = (worktree) => worktree.branch ?? worktree.path.split('/').at(-1) + ' (detached)'
10
11export function drawPane(
12 { Box, Text, Button, Code, Client, Input },
13 {
14 diff,
15 isWholeFile,
16 columns,
17 review,
18 worktrees,
19 target,
20 isFollowing,
21 isPickerOpen,
22 onToggle,
23 onRefresh,
24 onTogglePicker,
25 onPickWorktree,
26 onJump,
27 onTypeComment,
28 onSaveComment,
29 onCancelComment,
30 onDeleteDraft,
31 onSend,
32 },
33) {
34 // Review comments need the mouse, so they exist only where the surface has a Client.
35 const hasReview = Boolean(Client && review)
36 const drafts = hasReview ? review.drafts : []
37 const notes = hasReview && review.pending ? [...drafts, { ...review.pending, id: PENDING_ID }] : drafts
38 // While a comment is typed, its letters must reach the box, not the pane's hotkeys.
39 const hotkey = (key) => (hasReview && review.pending ? {} : { hotkey: key })
40
41 const column = (...children) => Box({ flexDirection: 'column', children })
42 const blank = () => Text({ children: [' '] })
43 const dim = (text) => Text({ dimColor: true, children: [text] })
44 const fileRule = () => Text({ bold: true, wrap: 'truncate-end', children: ['━'.repeat(columns || 40)] })
45 const tag = (file) => (file.isUntracked ? 'new, untracked' : file.isUncommitted ? 'uncommitted' : null)
46 const stat = (file) => [
47 Text({ color: 'diffAddedWord', children: ['+' + file.added] }),
48 Text({ color: 'diffRemovedWord', children: ['-' + file.removed] }),
49 ...(tag(file) ? [Text({ color: 'warning', children: ['● ' + tag(file)] })] : []),
50 ]
51
52 // Built from Buttons, not a Select: the terminal's Select takes no mouse picks.
53 const followLabel = 'follow Claude' + (isFollowing && target ? ` (${nameOf(target)})` : '')
54 const pick = (value) => onPickWorktree(value === FOLLOW ? null : value)
55 const option = (value, label) => Button({ key: 'worktree-' + value, label, plain: true, onPress: () => pick(value) })
56 const drawPicker = () => [
57 Button({
58 key: 'worktree',
59 label: `worktree: ${isFollowing ? followLabel : nameOf(target)} ${isPickerOpen ? '▴' : '▾'}`,
60 plain: true,
61 onPress: onTogglePicker,
62 }),
63 ...(isPickerOpen
64 ? [Box({ flexDirection: 'column', paddingLeft: 2, children: [option(FOLLOW, followLabel), ...worktrees.map((w) => option(w.path, nameOf(w)))] })]
65 : []),
66 ]
67 const choices = [FOLLOW, ...worktrees.map((w) => w.path)]
68 const nextChoice = () => choices[(choices.indexOf(isFollowing ? FOLLOW : target?.path) + 1) % choices.length]
69 // With one worktree there is nothing to pick.
70 const hasPicker = worktrees.length > 1
71 const picker = hasPicker ? drawPicker() : []
72 const nextButton = hasPicker
73 ? [Button({ key: 'next-worktree', label: 'next worktree', ...hotkey('w'), plain: true, onPress: () => pick(nextChoice()) })]
74 : []
75
76 const summary = 'vs ' + (diff.base ?? '?') + (diff.files ? ' · ' + plural(diff.files.length, 'file') : '')
77 const toolbar = Box({
78 flexDirection: 'row',
79 columnGap: 2,
80 children: [
81 Text({ bold: true, children: ['review-diff'] }),
82 dim(summary),
83 Button({ key: 'toggle', label: isWholeFile ? 'changes only' : 'whole files', ...hotkey('f'), plain: true, onPress: onToggle }),
84 Button({ key: 'refresh', label: 'refresh', ...hotkey('r'), plain: true, onPress: onRefresh }),
85 ...nextButton,
86 ...(drafts.length ? [Button({ key: 'send', label: 'send ' + plural(drafts.length, 'comment'), ...hotkey('s'), plain: true, onPress: onSend })] : []),
87 ],
88 })
89 const header = column(toolbar, ...picker)
90
91 // The comment box is keyed so it, and what is typed in it, survives blocks shifting around it on a refresh.
92 const drawNote = (note, where = '') =>
93 note.id === PENDING_ID
94 ? Box({
95 key: 'comment-row',
96 flexDirection: 'row',
97 columnGap: 2,
98 children: [
99 Input({ key: 'comment-box', label: where + note.label, placeholder: 'comment', submitLabel: 'save', autoFocus: true, onInput: onTypeComment, onSubmit: onSaveComment }),
100 Button({ key: 'cancel-comment', label: 'cancel', plain: true, onPress: onCancelComment }),
101 ],
102 })
103 : Box({
104 key: 'draft-' + note.id,
105 flexDirection: 'row',
106 columnGap: 2,
107 children: [
108 Text({ color: 'warning', children: [`${where}✎ ${note.label}: ${note.text}`] }),
109 Button({ key: 'delete-' + note.id, label: 'delete', plain: true, onPress: () => onDeleteDraft(note.id) }),
110 ],
111 })
112 // A note with no place in the drawn diff (its lines gone, its file binary or not shown) is listed by file instead.
113 const notesNotShown = (shownIds) => notes.filter((n) => !shownIds.has(n.id)).map((n) => drawNote(n, n.path + ' '))
114
115 if (diff.error) return column(header, ...notesNotShown(new Set()), blank(), Text({ color: 'error', children: [diff.error] }))
116 if (diff.files.length === 0) return column(header, ...notesNotShown(new Set()), blank(), Text({ children: ['No changes vs ' + diff.base] }))
117
118 const fileList = diff.files.map((file) =>
119 Box({
120 flexDirection: 'row',
121 columnGap: 1,
122 children: [Button({ key: 'goto-' + file.path, label: file.path, plain: true, onPress: () => onJump(file.path) }), ...stat(file)],
123 }),
124 )
125
126 const untrackedNote = diff.untrackedHiddenCount
127 ? [dim(plural(diff.untrackedHiddenCount, 'more untracked file') + ' not shown')]
128 : []
129
130 const reviewedPaths = new Set([...notes.map((n) => n.path), hasReview ? review.selection?.path : undefined])
131 const placements = new Map(
132 diff.files
133 .filter((file) => !file.isBinary && reviewedPaths.has(file.path))
134 .map((file) => [file.path, placeReview(file.path, fileRows(file), { selection: review.selection, notes })]),
135 )
136 const cutsByPath = new Map([...placements].map(([path, placement]) => [path, placement.cuts]))
137 const { sections, hiddenCount } = planSections(diff.files, cutsByPath)
138
139 const lastRowOf = (block) => block.start + block.count - 1
140 const notesUnder = (path, row) => notes.filter((n) => n.path === path && placements.get(path)?.endRows.get(n.id) === row)
141 const shownIds = new Set(sections.flatMap(({ file, blocks }) => blocks.flatMap((b) => notesUnder(file.path, lastRowOf(b)).map((n) => n.id))))
142
143 // Where the surface has a Client, the block hears the mouse; elsewhere it is the plain diff.
144 const drawBlock = (path, block) => {
145 if (!Client) return [Code({ source: block.source, format: 'diff', path })]
146 const marks = placements.get(path)?.marks.slice(block.start, lastRowOf(block) + 1) ?? ' '.repeat(block.count)
147 const props = { path, source: block.source, start: block.start, count: block.count, marks }
148 return [Client({ key: `block-${path}-${block.start}`, module: './diff-block.js', props }), ...notesUnder(path, lastRowOf(block)).map((n) => drawNote(n))]
149 }
150
151 const drawSection = ({ file, blocks, isCut }) => {
152 const body = file.isBinary ? [dim('Binary file, not shown')] : blocks.flatMap((block) => drawBlock(file.path, block))
153 if (!file.isBinary && file.hunks.length === 0) body.push(dim('No text changes'))
154 if (isCut) body.push(dim('… rest of this file not shown'))
155 return Box({
156 key: 'file-' + file.path,
157 flexDirection: 'column',
158 children: [fileRule(), Box({ flexDirection: 'row', columnGap: 1, children: [Text({ bold: true, children: [file.path] }), ...stat(file)] }), blank(), ...body, blank()],
159 })
160 }
161
162 const footer = hiddenCount
163 ? [dim(plural(hiddenCount, 'more file') + ' not shown' + (isWholeFile ? ' — press f for changed parts only' : ''))]
164 : []
165
166 return column(header, ...fileList, ...untrackedNote, ...notesNotShown(shownIds), blank(), ...sections.map(drawSection), fileRule(), ...footer)
167}
168hooks/diff-block.js 46 lines1// One diff block drawn with the mouse in mind: a one-cell mark strip beside the
2// engine's own diff drawing. Cut to one row per line, a pointer's `y` is the line.
3
4export default function DiffBlock(props, surface) {
5 const { Box, Text, Code } = surface.elements
6 // Held here as well as in state: a down and an up in one frame both reach this
7 // listener before setState redraws, so the up must see what the down kept.
8 let gesture = surface.state ?? {}
9 const remember = (next) => {
10 gesture = next
11 surface.setState(next)
12 }
13 const rowAt = (y) => props.start + Math.min(Math.max(y, 0), props.count - 1)
14 // Each post carries the whole gesture: a later post in the same frame replaces an earlier one.
15 const send = (type) =>
16 surface.post({ type, path: props.path, anchorRow: gesture.anchorRow, row: gesture.row, shift: gesture.shift })
17
18 surface.onPointer((e) => {
19 if (e.type === 'down' && e.button === 'left' && e.y >= 0 && e.y < props.count) {
20 const row = rowAt(e.y)
21 remember({ isDown: true, anchorRow: row, row, shift: Boolean(e.shift) })
22 send('press')
23 } else if (e.type === 'move' && e.button === 'left' && gesture.isDown) {
24 const row = rowAt(e.y)
25 if (row === gesture.row) return
26 remember({ ...gesture, row })
27 send('drag')
28 } else if (e.type === 'up' && gesture.isDown) {
29 send('release')
30 remember({ ...gesture, isDown: false })
31 }
32 })
33
34 return Box({
35 flexDirection: 'row',
36 children: [
37 Box({
38 flexDirection: 'column',
39 width: 1,
40 children: [...props.marks].map((mark) => Text({ color: 'warning', children: [mark] })),
41 }),
42 Box({ flexGrow: 1, children: [Code({ source: props.source, format: 'diff', path: props.path, wrap: 'truncate-end' })] }),
43 ],
44 })
45}
46types/index.d.ts 21 lines1// A row by the line number it keeps after a refresh: its new line, else its old one.
2export type RowRef = { side: 'new' | 'old'; no: number }
3
4// A review comment kept until it is sent: the lines it is on, and what it says.
5export type Draft = {
6 id: number
7 path: string
8 from: RowRef
9 to: RowRef
10 label: string
11 snippet: string
12 text: string
13 worktree?: string // the worktree's folder; absent when git listed none
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 'review-diff': { drafts: Draft[] }
19 }
20}
21