SLOPSHOPPER

review-diff

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

newpaneguardcommandprocesstimer
v1.0.0no licenseupdated 2026-10-09DiegoHeer/claude-mods/review-diff
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · review-diff
│ ┃ Review diff ✕ › fix the failing auth test and add an audit log call │ ┃ review-diff vs feat/auth-refresh f: change │ ┃ ⏺ Read(src/auth.ts) │ ┃ This branch shares no history with ⎿ Read 6 lines │ ┃ feat/auth-refresh. ⏺ 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 │ │ › /review-diff │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Review diff
review-diff vs feat/auth-refresh f: changes only r: refre This branch shares no history with feat/auth-refresh.
README

claude-mods

My Claude Code mods, shared as a plugin marketplace. Mods need Claude Code v2.1.287 or later.

Install

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.

Mods

review-diff

/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 only
  • r refreshes; the pane also refreshes on its own after Claude edits files or runs commands
  • w steps to the next worktree, then back to follow Claude (shown with two or more worktrees)
  • Press a file in the list to jump to it
  • Files with uncommitted changes carry a ● uncommitted tag
  • Untracked files (not ignored) show in full with a ● 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):

  • Click a line to comment on it; drag or shift-click to pick several lines
  • Type the comment and press Enter to keep it as a draft (✎ marks its lines)
  • s sends all drafts to Claude as one review message

Develop

claude plugin validate ./review-diff
claude plugin test ./review-diff
claude --plugin-dir ./review-diff
Source 6 files
hooks/register.js 230 lines
1// /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}
230
hooks/git.js 178 lines
1// 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}
178
hooks/rows.js 156 lines
1// 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}
156
hooks/view.js 168 lines
1// 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}
168
hooks/diff-block.js 46 lines
1// 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}
46
types/index.d.ts 21 lines
1// 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