/multi-diff: a worktree-aware diff viewer for every git repo in your project folder, with uncommitted, staged, pull request and since-this-session views

A Claude Code mod that shows the changes in every git repo in a folder in one pane.
/multi-diff scans the session's folder. The folder itself is included when it is a repo, along with every child folder that has a .git./multi-diff <path> scans another folder.origin/main (or the repo's default branch)/clear or a new conversation starts a new one. The snapshots live in each repo's .git under refs/multirepo-diff/ and are removed after 30 days. Your files, branches and staged changes are never touched.In the Desktop app, long diffs and file lists are shown a page at a time (Previous/Next), because very large panes stop responding there.
claude --plugin-dir /path/to/multirepo-diff-mod
Or add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. Sessions started by the desktop app will then load it, and it hot-reloads when you edit it.
It sends nothing anywhere. The mod makes no network requests and doesn't read your conversation with Claude. It only reads your files through git, and shows the result in its own pane.
The only program it runs is git, in the folder you open and the repos and worktrees inside it. The git commands, and why:
| Command | Why |
|---|---|
git --version | Check git works before scanning |
git rev-parse | Find a repo's root, its main checkout, HEAD, and git's own folder paths |
git branch --show-current, git symbolic-ref, git merge-base | Name the branch and find the base for the pull request view |
git worktree list | List a repo's worktrees |
git status, git diff, git ls-files | List changed files and show diffs |
git read-tree, git add -A, git write-tree | Build a session snapshot, using the mod's own index file, never yours |
git update-ref, git for-each-ref | Keep and clean up its snapshot refs |
What it writes, all inside each repo's .git folder:
refs/multirepo-diff/ that points to it, and deletes its own refs after 30 days..git/multirepo-diff-index, used to build those snapshots.It never commits, changes branches, edits your files or touches your staging area. It also reads the folder you open, one level deep, to find repos, and remembers your light/dark choice in Claude Code's plugin storage.
How it hooks into Claude Code: it adds the /multi-diff command and answers only that command. It watches Claude's file-editing and shell tools only to refresh its pane after they run; it passes every tool call through unchanged and never approves, blocks or alters one.
To remove the snapshots from a repo, run git for-each-ref --format='%(refname)' refs/multirepo-diff/ | xargs -n1 git update-ref -d in it, and delete .git/multirepo-diff-index.
Tested on macOS, in the terminal and the Desktop app. It is written to work on Linux and Windows too, but it has not been tested on Windows yet. If you try it there, please open an issue whether it works or not.
Bug reports and feature requests are welcome as issues. This project doesn't accept pull requests; see CONTRIBUTING.md. Report security problems privately, as described in SECURITY.md.
claude plugin validate .
claude plugin test .
The Claude Code function-hooks API is in early access and may change between releases.
MIT. See LICENSE.
hooks/register.tsx 871 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DiffMode, DiffView, FileChange, Repo, Theme, Worktree } from '../types'
5import { toRows } from './diff'
6import { DIFF, baseName, isAbsolute, join, parseNameStatus, parseStatus, parseWorktrees, folderHash, toBlocks, samePath, toSlash, toTree, toView, untracked } from './parse'
7
8const PANE = 'multi-diff'
9const COMMAND = 'multi-diff'
10
11const root = atom({ plugin: 'multirepo-diff-mod', key: 'root' } as const, '')
12const repos = atom({ plugin: 'multirepo-diff-mod', key: 'repos' } as const, [])
13const repo = atom({ plugin: 'multirepo-diff-mod', key: 'repo' } as const, '')
14const worktrees = atom({ plugin: 'multirepo-diff-mod', key: 'worktrees' } as const, [])
15const worktree = atom({ plugin: 'multirepo-diff-mod', key: 'worktree' } as const, '')
16const showDetached = atom({ plugin: 'multirepo-diff-mod', key: 'showDetached' } as const, false)
17const mode = atom({ plugin: 'multirepo-diff-mod', key: 'mode' } as const, 'head')
18const note = atom({ plugin: 'multirepo-diff-mod', key: 'note' } as const, '')
19const files = atom({ plugin: 'multirepo-diff-mod', key: 'files' } as const, [])
20const file = atom({ plugin: 'multirepo-diff-mod', key: 'file' } as const, '')
21const diff = atom({ plugin: 'multirepo-diff-mod', key: 'diff' } as const, { kind: 'empty' })
22const error = atom({ plugin: 'multirepo-diff-mod', key: 'error' } as const, '')
23const isListCollapsed = atom({ plugin: 'multirepo-diff-mod', key: 'isListCollapsed' } as const, false)
24const collapsedDirs = atom({ plugin: 'multirepo-diff-mod', key: 'collapsedDirs' } as const, [])
25const theme = atom({ plugin: 'multirepo-diff-mod', key: 'theme' } as const, 'light')
26const diffPage = atom({ plugin: 'multirepo-diff-mod', key: 'diffPage' } as const, 0)
27const treePage = atom({ plugin: 'multirepo-diff-mod', key: 'treePage' } as const, 0)
28
29type Engine = EngineInterface
30
31const EMPTY: DiffView = { kind: 'empty' }
32
33const MODES: readonly { value: DiffMode; label: string; empty: string }[] = [
34 { value: 'head', label: 'Uncommitted vs HEAD', empty: 'No uncommitted changes' },
35 { value: 'unstaged', label: 'Unstaged vs staged', empty: 'No unstaged changes' },
36 { value: 'staged', label: 'Staged vs HEAD', empty: 'Nothing is staged' },
37 { value: 'base', label: 'Branch vs base (PR)', empty: 'No commits ahead of the base' },
38 { value: 'session', label: 'Changes since this session started', empty: 'No changes since this session started' },
39]
40
41
42function git($: Engine, cwd: string, args: string[], env?: Record<string, string>, timeoutMs = 20000) {
43 return $.process.run(['git', '--no-pager', ...args], { cwd, env, timeoutMs })
44}
45
46// --- discovery -------------------------------------------------------------
47
48async function isRepoRoot($: Engine, dir: string) {
49 const r = await git($, dir, ['rev-parse', '--show-toplevel'])
50 return r.exitCode === 0 && samePath(r.stdout.trim(), dir)
51}
52
53/** The main checkout a repo or linked worktree belongs to (its common .git's folder). */
54async function mainCheckout($: Engine, path: string) {
55 const r = await git($, path, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
56 const common = toSlash(r.stdout.trim())
57 return r.exitCode === 0 && common.endsWith('/.git') ? common.slice(0, -'/.git'.length) : path
58}
59
60/**
61 * The folder itself (when it is a repo root) plus every child folder holding
62 * a `.git`. A linked worktree of a repo already listed is left out: it shows
63 * in that repo's worktree picker instead.
64 */
65async function discover($: Engine, dir: string): Promise<Repo[]> {
66 const candidates: Repo[] = []
67 if (await isRepoRoot($, dir)) candidates.push({ name: `${baseName(dir)} (main)`, path: dir })
68
69 const dirs = (await $.fs.list(dir))
70 // Symlinked folders list as `other`; the `.git` check below sorts them out.
71 .filter(one => one.kind !== 'file' && !one.name.startsWith('.') && one.name !== 'node_modules')
72 .map(one => one.name)
73 .sort((a, b) => a.localeCompare(b))
74 const hasGit = await Promise.all(dirs.map(name => $.fs.exists(join(join(dir, name), '.git'))))
75 dirs.forEach((name, i) => {
76 if (hasGit[i]) candidates.push({ name, path: join(dir, name) })
77 })
78
79 const mains = await Promise.all(candidates.map(one => mainCheckout($, one.path)))
80 return candidates.filter((one, i) => samePath(mains[i], one.path) || !candidates.some(other => samePath(other.path, mains[i])))
81}
82
83/** Every worktree of the repo, its main checkout first. */
84async function listWorktrees($: Engine, repoPath: string): Promise<Worktree[]> {
85 const r = await git($, repoPath, ['worktree', 'list', '--porcelain'])
86 const list = r.exitCode === 0 ? parseWorktrees(r.stdout) : []
87 return list.length ? list : [{ path: repoPath, branch: null, sha: '' }]
88}
89
90/** What HEAD is in a checkout, for the comparison line: the branch, or "detached at <sha>". */
91async function headLabel($: Engine, cwd: string) {
92 const [branch, sha] = await Promise.all([
93 git($, cwd, ['branch', '--show-current']),
94 git($, cwd, ['rev-parse', '--short', 'HEAD']),
95 ])
96 const short = sha.exitCode === 0 ? sha.stdout.trim() : ''
97 return { name: branch.stdout.trim() || (short ? `detached at ${short}` : 'no commits yet'), sha: short }
98}
99
100// --- the base of a branch --------------------------------------------------
101
102type Base = { ref: string; sha: string }
103
104/**
105 * Where this branch left the default branch: the merge base of HEAD and the
106 * remote's default branch (origin/HEAD), else origin/main, origin/master,
107 * main or master, the first that exists.
108 */
109async function findBase($: Engine, cwd: string): Promise<Base | null> {
110 const remoteHead = await git($, cwd, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])
111 const candidates = [remoteHead.stdout.trim(), 'origin/main', 'origin/master', 'main', 'master']
112 for (const ref of [...new Set(candidates.filter(Boolean))]) {
113 const exists = await git($, cwd, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
114 if (exists.exitCode !== 0) continue
115 const mergeBase = await git($, cwd, ['merge-base', 'HEAD', ref])
116 if (mergeBase.exitCode === 0) return { ref, sha: mergeBase.stdout.trim() }
117 }
118 return null
119}
120
121// --- changed files per mode ------------------------------------------------
122
123type Listing = { files: FileChange[]; note: string; error?: string }
124
125
126async function listChanges(
127 $: Engine,
128 cwd: string,
129 mode: DiffMode,
130): Promise<Listing> {
131 // The comparison line (row 4): what is compared with what, on which branch.
132 const head = await headLabel($, cwd)
133 const at = head.sha ? ` (${head.sha})` : ''
134 const note = {
135 head: `${head.name}: working tree vs HEAD${at}`,
136 unstaged: `${head.name}: working tree vs staging area`,
137 staged: `${head.name}: staging area vs HEAD${at}`,
138 base: '',
139 session: '',
140 }[mode]
141 const fail = (r: { stderr: string }, what: string): Listing => ({
142 files: [],
143 note,
144 error: r.stderr.trim() || `${what} failed`,
145 })
146
147 if (mode === 'head') {
148 const r = await git($, cwd, ['status', '--porcelain=v1', '-uall', '-z'])
149 return r.exitCode === 0 ? { files: parseStatus(r.stdout), note } : fail(r, 'git status')
150 }
151
152 if (mode === 'unstaged') {
153 const [changed, fresh] = await Promise.all([
154 git($, cwd, ['diff', '--name-status', '-z', '-M']),
155 git($, cwd, ['ls-files', '--others', '--exclude-standard', '-z']),
156 ])
157 if (changed.exitCode !== 0) return fail(changed, 'git diff')
158 return { files: [...parseNameStatus(changed.stdout), ...untracked(fresh.stdout)], note }
159 }
160
161 if (mode === 'staged') {
162 const r = await git($, cwd, ['diff', '--cached', '--name-status', '-z', '-M'])
163 return r.exitCode === 0 ? { files: parseNameStatus(r.stdout), note } : fail(r, 'git diff --cached')
164 }
165
166 if (mode === 'base') {
167 const base = await findBase($, cwd)
168 if (!base) {
169 return { files: [], note: '', error: 'No base branch found (looked for origin/HEAD, origin/main, origin/master, main, master).' }
170 }
171 const r = await git($, cwd, ['diff', '--name-status', '-z', '-M', base.sha, 'HEAD'])
172 if (r.exitCode !== 0) return fail(r, 'git diff')
173 return {
174 files: parseNameStatus(r.stdout),
175 note: `${head.name} vs ${base.ref}: commits since merge base ${base.sha.slice(0, 7)} (uncommitted changes not included)`,
176 }
177 }
178
179 // session: the snapshot from the session's start vs the files now
180 const base = await baseline($, cwd)
181 const now = await writeTree($, cwd)
182 const r = await git($, cwd, ['diff', '--name-status', '-z', '-M', base.tree, now])
183 if (r.exitCode !== 0) return fail(r, 'git diff')
184 const since = base.isLate ? 'when this pane first saw this checkout' : 'this session started'
185 return {
186 files: parseNameStatus(r.stdout).map(one => ({ ...one, trees: { base: base.tree, now } })),
187 note: `${head.name}: every change since ${since}, by Claude or anyone (snapshot ${base.tree.slice(0, 7)})`,
188 }
189}
190
191// --- one file's diff -------------------------------------------------------
192
193async function fileDiff($: Engine, cwd: string, mode: DiffMode, change: FileChange): Promise<DiffView> {
194 const paths = change.from ? ['--', change.from, change.path] : ['--', change.path]
195
196 if (mode === 'session' && change.trees) {
197 const r = await git($, cwd, [...DIFF, change.trees.base, change.trees.now, ...paths])
198 return toView(r.stdout)
199 }
200
201 if (change.status === '??') {
202 const r = await git($, cwd, [...DIFF, '--no-index', '--', '/dev/null', change.path])
203 return toView(r.stdout)
204 }
205
206 if (mode === 'unstaged') return toView((await git($, cwd, [...DIFF, ...paths])).stdout)
207 if (mode === 'staged') return toView((await git($, cwd, [...DIFF, '--cached', ...paths])).stdout)
208
209 if (mode === 'base') {
210 const base = await findBase($, cwd)
211 if (!base) return { kind: 'text', text: 'No base branch found.' }
212 return toView((await git($, cwd, [...DIFF, base.sha, 'HEAD', ...paths])).stdout)
213 }
214
215 // head
216 const vsHead = await git($, cwd, [...DIFF, 'HEAD', ...paths])
217 if (vsHead.exitCode === 0) return toView(vsHead.stdout)
218 // No HEAD yet (fresh repo): staged, then unstaged.
219 const [staged, unstaged] = await Promise.all([
220 git($, cwd, [...DIFF, '--cached', ...paths]),
221 git($, cwd, [...DIFF, ...paths]),
222 ])
223 return toView(`${staged.stdout}\n${unstaged.stdout}`)
224}
225
226// --- actions ---------------------------------------------------------------
227
228// Loads overlap (a slow base lookup, a refresh after an edit, quick clicks), so
229// each load takes a ticket and only the newest one may write its result.
230// Module variables are fine here: a reload just starts the count over.
231let listTicket = 0
232let diffTicket = 0
233
234async function showFile($: Engine, path: string) {
235 const ticket = ++diffTicket
236 await update($, file, () => path)
237 await update($, diffPage, () => 0)
238 const [cwd, current, list] = await Promise.all([read($, worktree), read($, mode), read($, files)])
239 const change = list.find(one => one.path === path)
240 if (!cwd || !change) {
241 if (ticket === diffTicket) await update($, diff, () => EMPTY)
242 return
243 }
244 let view: DiffView
245 try {
246 view = await fileDiff($, cwd, current, change)
247 } catch (err) {
248 view = { kind: 'text', text: `Could not load diff: ${String(err)}` }
249 }
250 if (ticket === diffTicket) await update($, diff, () => view)
251}
252
253/** Lists the changes of one checkout: the repo's main path or one of its worktrees. */
254async function showTree($: Engine, wanted: string) {
255 const ticket = ++listTicket
256 // Worktrees come and go (added, moved, removed) outside the pane, so the
257 // picker's list is reloaded on every load rather than only on a repo pick,
258 // and a worktree that is gone falls back to the main checkout.
259 let path = wanted
260 const repoPath = await read($, repo)
261 if (repoPath) {
262 const trees = await listWorktrees($, repoPath)
263 await update($, worktrees, () => trees)
264 // Use git's own spelling of the path, so it matches the picker's options.
265 path = (trees.find(one => samePath(one.path, path)) ?? trees[0]).path
266 }
267 await update($, worktree, () => path)
268 const current = await read($, mode)
269 let listing: Listing
270 try {
271 listing = await listChanges($, path, current)
272 } catch (err) {
273 listing = { files: [], note: '', error: String(err) }
274 }
275 // A newer load started meanwhile: drop this stale result.
276 if (ticket !== listTicket) return
277 await update($, treePage, () => 0)
278 await update($, note, () => listing.note)
279 await update($, error, () => listing.error ?? '')
280 const list = [...listing.files].sort((a, b) => a.path.localeCompare(b.path))
281 await update($, files, () => list)
282 if (list.length === 0) {
283 await update($, file, () => '')
284 await update($, diff, () => EMPTY)
285 return
286 }
287 const keep = await read($, file)
288 await showFile($, list.some(one => one.path === keep) ? keep : list[0].path)
289}
290
291/**
292 * Picks a repo: loads its worktrees and shows the main checkout, or `keep`
293 * when that is one of its worktrees (so a refresh stays on the same one).
294 */
295async function selectRepo($: Engine, repoPath: string, keep?: string) {
296 await update($, repo, () => repoPath)
297 const trees = await listWorktrees($, repoPath)
298 await update($, worktrees, () => trees)
299 const kept = keep ? trees.find(one => samePath(one.path, keep)) : undefined
300 await showTree($, (kept ?? trees[0]).path)
301}
302
303async function setMode($: Engine, next: DiffMode) {
304 await update($, mode, () => next)
305 const current = await read($, worktree)
306 // No repo picked yet (e.g. right after a restart): rescan and pick one.
307 if (current) await showTree($, current)
308 else await refresh($)
309}
310
311async function refresh($: Engine, dir?: string) {
312 const where = toSlash(dir || (await read($, root)) || (await $.session.cwd()))
313 await update($, root, () => where)
314 try {
315 // A broken git (e.g. macOS's Xcode stub before its license is accepted)
316 // would otherwise look like "no repos found".
317 const probe = await git($, where, ['--version'])
318 if (probe.exitCode !== 0) {
319 await update($, repos, () => [])
320 await update($, files, () => [])
321 await update($, error, () => `git is not usable: ${(probe.stderr || probe.stdout).trim()}`)
322 return
323 }
324 const found = await discover($, where)
325 await update($, repos, () => found)
326 if (found.length === 0) {
327 await update($, files, () => [])
328 await update($, error, () => `No git repositories found in ${where}`)
329 return
330 }
331 const [current, tree] = await Promise.all([read($, repo), read($, worktree)])
332 await selectRepo($, (found.find(one => samePath(one.path, current)) ?? found[0]).path, tree)
333 } catch (err) {
334 await update($, error, () => String(err))
335 }
336}
337
338async function toggleTheme($: Engine) {
339 const next = await update($, theme, current => (current === 'light' ? 'dark' : 'light'))
340 await $.store.set(THEME_STORE_KEY, next)
341}
342
343/** Shows or hides detached worktrees in the picker; hiding moves off one. */
344async function toggleDetached($: Engine) {
345 const isShowing = await update($, showDetached, shown => !shown)
346 if (isShowing) return
347 const [trees, current] = await Promise.all([read($, worktrees), read($, worktree)])
348 const viewed = trees.find((one, i) => i > 0 && samePath(one.path, current))
349 if (viewed && !viewed.branch) await showTree($, trees[0].path)
350}
351
352async function toggleList($: Engine) {
353 await update($, isListCollapsed, hidden => !hidden)
354}
355
356async function toggleDir($: Engine, path: string) {
357 await update($, collapsedDirs, list => (list.includes(path) ? list.filter(one => one !== path) : [...list, path]))
358}
359
360// --- session snapshots -----------------------------------------------------
361//
362// "Changes since this session started" compares each checkout with a git
363// snapshot taken when the conversation began: a tree of every file (tracked,
364// plus untracked ones .gitignore doesn't exclude), written through a temporary
365// index so the real staging area, branches and files are never touched. A ref
366// under refs/multirepo-diff/ keeps git from pruning it.
367//
368// Snapshots are keyed by the session's start time, which stays the same when
369// a conversation is resumed after a restart, and starts over on /clear or in a
370// new conversation.
371
372const SNAPSHOT_REFS = 'refs/multirepo-diff'
373
374// Snapshots older than this are deleted when a new one is taken.
375const SNAPSHOT_KEEP_MS = 30 * 24 * 60 * 60 * 1000
376
377// A snapshot taken this soon after the session started counts as taken at its start.
378const SESSION_START_GRACE_MS = 2 * 60 * 1000
379
380// Writing a tree of a big checkout can take a while.
381const WRITE_TREE_TIMEOUT_MS = 120_000
382
383/**
384 * Writes the checkout's files as they are now into git's store; returns the
385 * tree id. It uses a throwaway index file inside the checkout's own git folder
386 * (git rev-parse --git-path gives it, worktrees included), seeded from HEAD so
387 * git only hashes what changed. The real index is never touched.
388 */
389async function writeTree($: Engine, cwd: string) {
390 const indexPath = await git($, cwd, ['rev-parse', '--path-format=absolute', '--git-path', 'multirepo-diff-index'])
391 if (indexPath.exitCode !== 0) throw new Error(indexPath.stderr.trim() || 'git rev-parse failed')
392 const env = { GIT_INDEX_FILE: indexPath.stdout.trim() }
393 await git($, cwd, ['read-tree', 'HEAD'], env) // fails harmlessly in a repo with no commits
394 const added = await git($, cwd, ['add', '-A'], env, WRITE_TREE_TIMEOUT_MS)
395 if (added.exitCode !== 0) throw new Error(added.stderr.trim() || 'git add failed')
396 const tree = await git($, cwd, ['write-tree'], env)
397 if (tree.exitCode !== 0) throw new Error(tree.stderr.trim() || 'git write-tree failed')
398 return tree.stdout.trim()
399}
400
401/** This session's snapshot ref for a checkout. */
402async function snapshotRef($: Engine, cwd: string) {
403 const { startedAt } = await $.session.usage()
404 return `${SNAPSHOT_REFS}/${startedAt}/${folderHash(cwd)}`
405}
406
407/**
408 * The checkout's snapshot from this session's start, taking it now if there is
409 * none (`isLate`: the checkout wasn't seen when the session started).
410 */
411async function baseline($: Engine, cwd: string, isAtStart = false) {
412 const ref = await snapshotRef($, cwd)
413 const found = await git($, cwd, ['rev-parse', '--verify', '--quiet', ref])
414 if (found.exitCode === 0) return { tree: found.stdout.trim(), isLate: false }
415 const tree = await writeTree($, cwd)
416 await git($, cwd, ['update-ref', ref, tree])
417 await pruneSnapshots($, cwd)
418 return { tree, isLate: !isAtStart }
419}
420
421/** Deletes this repo's snapshots from sessions that started more than SNAPSHOT_KEEP_MS ago. */
422async function pruneSnapshots($: Engine, cwd: string) {
423 const now = await $.clock.now()
424 const refs = await git($, cwd, ['for-each-ref', '--format=%(refname)', SNAPSHOT_REFS])
425 for (const ref of refs.stdout.split('\n').filter(Boolean)) {
426 const startedAt = Number(ref.slice(SNAPSHOT_REFS.length + 1).split('/')[0])
427 if (startedAt && now - startedAt > SNAPSHOT_KEEP_MS) await git($, cwd, ['update-ref', '-d', ref])
428 }
429}
430
431/** Takes the session-start snapshot of every checkout in the folder that has none yet. */
432async function snapshotFolder($: Engine, dir: string) {
433 // session.start also fires when the mod reloads mid-session; a snapshot
434 // taken then is honest only as "since the pane first saw it".
435 const [{ startedAt }, now] = await Promise.all([$.session.usage(), $.clock.now()])
436 const isAtStart = now - startedAt < SESSION_START_GRACE_MS
437 for (const found of await discover($, dir)) {
438 for (const tree of await listWorktrees($, found.path)) {
439 await baseline($, tree.path, isAtStart).catch(() => undefined)
440 }
441 }
442}
443
444/** After a tool that may change files runs, refresh the open view so the change shows. */
445async function refreshAfter($: Engine, e: object, next: (e: never) => Promise<unknown>) {
446 const result = await next(e as never)
447 const current = await read($, worktree)
448 if (current) await showTree($, current).catch(() => undefined)
449 return result
450}
451
452// --- presses and picks -----------------------------------------------------
453
454// The desktop app (engine 2.1.286) logs "ui_press not handled" for presses
455// that should reach a Button's onPress closure, so every control in the pane
456// is also answered here by its key, at the ui.press / ui.select events.
457
458async function handlePress($: Engine, key: string) {
459 if (key === 'refresh') await refresh($)
460 else if (key === 'theme') await toggleTheme($)
461 else if (key === 'files') await toggleList($)
462 else if (key === 'detached') await toggleDetached($)
463 else if (key.startsWith('dir:')) await toggleDir($, key.slice('dir:'.length))
464 else if (key.startsWith('file:')) await showFile($, key.slice('file:'.length))
465 else if (key.startsWith('prev-')) await turnPage($, -1)
466 else if (key.startsWith('next-')) await turnPage($, 1)
467 else if (key === 'tree-prev') await turnTreePage($, -1)
468 else if (key === 'tree-next') await turnTreePage($, 1)
469}
470
471async function turnTreePage($: Engine, by: number) {
472 await update($, treePage, n => Math.max(0, n + by))
473}
474
475async function turnPage($: Engine, by: number) {
476 await update($, diffPage, n => Math.max(0, n + by))
477}
478
479async function handleSelect($: Engine, key: string, value: string) {
480 if (key === 'repo') await selectRepo($, value)
481 else if (key === 'worktree') await showTree($, value)
482 else if (key === 'mode') await setMode($, value as DiffMode)
483}
484
485// --- drawing ---------------------------------------------------------------
486
487const STATUS_COLOR: Record<string, string> = {
488 M: 'yellow',
489 A: 'green',
490 '?': 'green',
491 D: 'red',
492 R: 'cyan',
493 C: 'cyan',
494 U: 'magenta',
495}
496
497// Desktop only. The terminal draws diffs with Claude Code's own `Code
498// format="diff"`, but the desktop app (engine 2.1.286) draws that as plain
499// text, so there the pane draws its own rows: a tinted row, a stronger tint on
500// the words that changed, a colored marker and line number, and a text color
501// of the palette's own. Plugins aren't told the app's theme, so the person
502// picks with the ☀/☾ toggle. Solid hex only: the desktop did not draw rgba().
503type RowLook = { mark: string; background?: string; word?: string; marker?: string; number?: string; text?: string }
504type Looks = Record<'add' | 'del' | 'context', RowLook>
505
506const LOOKS: Record<Theme, Looks> = {
507 light: {
508 add: { mark: '+', background: '#e6ffec', word: '#abf2bc', marker: '#1a7f37', number: '#1a7f37', text: '#1f2328' },
509 del: { mark: '-', background: '#ffebe9', word: '#ffc1bc', marker: '#cf222e', number: '#cf222e', text: '#1f2328' },
510 context: { mark: ' ' },
511 },
512 dark: {
513 add: { mark: '+', background: '#1c3a27', word: '#2f6b40', marker: '#7ee787', number: '#7ee787', text: '#e6edf3' },
514 del: { mark: '-', background: '#4b1e24', word: '#80303a', marker: '#ff8a80', number: '#ff8a80', text: '#e6edf3' },
515 context: { mark: ' ' },
516 },
517}
518
519const THEME_STORE_KEY = 'theme'
520
521// The bar between the file tree and the diff, per palette.
522const SEPARATOR: Record<Theme, string> = { light: '#d0d7de', dark: '#3d444d' }
523
524// Diff rows drawn per page on desktop. Past ~300 rows in one drawing the
525// desktop stops applying redraws and the pane's controls go dead.
526const PAGE_ROWS = 150
527
528// File-tree rows (folders and files) drawn per page, for the same reason.
529const TREE_ROWS = 60
530
531function statusLetter(status: string) {
532 if (status === '??') return '?'
533 const s = status.trim()
534 return s.length ? s[0] : '·'
535}
536
537export const register: Register = on => {
538 on('session.start', async ($, e, next) => {
539 // Restore the light/dark pick saved in an earlier session.
540 const saved = await $.store.get(THEME_STORE_KEY)
541 if (saved === 'light' || saved === 'dark') await update($, theme, () => saved)
542 await $.command.register({
543 name: COMMAND,
544 description: 'Browse uncommitted changes across every git repo in this folder',
545 })
546 // Snapshot every checkout in the folder for "Changes since this session
547 // started". Off the startup path, so the first prompt doesn't wait on git.
548 // A resumed session already has its snapshots and keeps them.
549 $.clock.after(0, () => void snapshotFolder($, toSlash(e.cwd)).catch(() => undefined))
550 return next(e)
551 })
552
553 // `/multi-diff` scans the session's folder; `/multi-diff <dir>` scans another.
554 on('command.run', { command: COMMAND }, async ($, e) => {
555 const arg = e.args.trim()
556 const target = toSlash(isAbsolute(arg) ? arg : arg ? join(await $.session.cwd(), arg) : await $.session.cwd())
557 await refresh($, target)
558 await $.ui.open({ id: PANE, title: 'Multi-repo diff', focus: true })
559 const found = await read($, repos)
560 return { text: `Multi-repo diff: ${found.length} repo(s) in ${target}.` }
561 })
562
563 // Refresh the open view after the tools that can change files, shell commands included.
564 on('tool.call', { tool: 'Edit' }, ($, e, next) => refreshAfter($, e, next as never))
565 on('tool.call', { tool: 'MultiEdit' }, ($, e, next) => refreshAfter($, e, next as never))
566 on('tool.call', { tool: 'Write' }, ($, e, next) => refreshAfter($, e, next as never))
567 on('tool.call', { tool: 'NotebookEdit' }, ($, e, next) => refreshAfter($, e, next as never))
568 on('tool.call', { tool: 'Bash' }, ($, e, next) => refreshAfter($, e, next as never))
569
570 // Answer the pane's controls by key instead of relying on their closures.
571 on('ui.press', { requestId: PANE }, async ($, e) => {
572 await handlePress($, e.element)
573 return { element: e.element }
574 })
575 on('ui.select', { requestId: PANE }, async ($, e) => {
576 await handleSelect($, e.element, e.value)
577 return { element: e.element, value: e.value }
578 })
579
580 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
581 const table = $.ui.resolve(e)
582 const { Box, Text, Button, Code } = table
583 // Mobile has no Select; it shows the repo name instead.
584 const Select = 'Select' in table ? table.Select : null
585
586 const [repoList, current, currentMode, about, list, selected, view, problem, where, isListHidden, foldedDirs, colorTheme, page, treeAt, trees, activeTree, isShowingDetached] =
587 await Promise.all([
588 read($, repos),
589 read($, repo),
590 read($, mode),
591 read($, note),
592 read($, files),
593 read($, file),
594 read($, diff),
595 read($, error),
596 read($, root),
597 read($, isListCollapsed),
598 read($, collapsedDirs),
599 read($, theme),
600 read($, diffPage),
601 read($, treePage),
602 read($, worktrees),
603 read($, worktree),
604 read($, showDetached),
605 ])
606
607 const cols = e.props.bodyColumns || e.viewport?.columns || 100
608 const isWide = cols >= 70
609 const leftWidth = isWide ? Math.max(24, Math.min(48, Math.floor(cols * 0.3))) : cols
610 const active = repoList.find(one => one.path === current)
611 const modeInfo = MODES.find(one => one.value === currentMode) ?? MODES[0]
612
613 const repoOptions = repoList.map(one => ({ value: one.path, label: one.name }))
614 // Detached worktrees (no branch checked out, e.g. scratch checkouts) are
615 // hidden unless asked for. The main checkout always shows, and so does
616 // the worktree being viewed.
617 const detachedCount = trees.filter((one: Worktree, i: number) => i > 0 && !one.branch).length
618 const treeOptions = trees
619 .map((one: Worktree, i: number) => ({ one, i }))
620 .filter(({ one, i }) => i === 0 || one.branch || isShowingDetached || one.path === activeTree)
621 .map(({ one, i }) => ({
622 value: one.path,
623 label: `${i === 0 ? 'main checkout' : baseName(one.path)} — ${one.branch ?? `detached at ${one.sha}`}`,
624 }))
625 const fileCount = `${list.length} file${list.length === 1 ? '' : 's'} changed`
626
627 // Four rows: repo and pane controls; worktree; compare mode and count;
628 // what exactly is being compared.
629 const header = (
630 <Box flexDirection="column" gap={1}>
631 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
632 <Box flexDirection="row" gap={2} alignItems="center" flexShrink={1}>
633 <Button key="files" plain onPress={() => void toggleList($)}>
634 ☰
635 </Button>
636 {repoOptions.length > 0 && Select ? (
637 <Select
638 key="repo"
639 label="Repo: "
640 options={repoOptions}
641 value={current || repoOptions[0].value}
642 autoFocus
643 onSelect={value => void selectRepo($, value)}
644 />
645 ) : (
646 <Text bold>{active ? active.name : 'Multi-repo diff'}</Text>
647 )}
648 </Box>
649 <Box flexDirection="row" gap={1} alignItems="center">
650 <Button key="refresh" plain onPress={() => void refresh($)}>
651 ⟳
652 </Button>
653 {/* Shows the mode a press switches to: ☾ while light, ☀ while dark. */}
654 {e.surface !== 'terminal' && (
655 <Button key="theme" plain onPress={() => void toggleTheme($)}>
656 {colorTheme === 'light' ? '☾' : '☀'}
657 </Button>
658 )}
659 </Box>
660 </Box>
661 {treeOptions.length > 0 && Select ? (
662 <Box flexDirection="row" gap={2} alignItems="center">
663 <Select
664 key="worktree"
665 label="Worktree: "
666 options={treeOptions}
667 value={activeTree || treeOptions[0].value}
668 onSelect={value => void showTree($, value)}
669 />
670 {detachedCount > 0 && (
671 <Button key="detached" plain dimColor onPress={() => void toggleDetached($)}>
672 {isShowingDetached ? 'Hide detached' : `Show detached (${detachedCount})`}
673 </Button>
674 )}
675 </Box>
676 ) : null}
677 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
678 {Select ? (
679 <Select
680 key="mode"
681 label="Compare: "
682 options={MODES.map(({ value, label }) => ({ value, label }))}
683 value={currentMode}
684 onSelect={value => void setMode($, value as DiffMode)}
685 />
686 ) : (
687 <Text dimColor>{modeInfo.label}</Text>
688 )}
689 <Text dimColor>{fileCount}</Text>
690 </Box>
691 {about ? <Text dimColor>{about}</Text> : null}
692 </Box>
693 )
694
695 // Folders first, files under them; the letter in front is the git status.
696 // Paged like the diff, so a repo with many changed files can't push the
697 // drawing past the desktop's size limit.
698 const tree = toTree(list, new Set(foldedDirs))
699 const treePages = Math.ceil(tree.length / TREE_ROWS)
700 const treeStart = Math.min(treeAt, Math.max(0, treePages - 1)) * TREE_ROWS
701 const treeShown = tree.slice(treeStart, treeStart + TREE_ROWS)
702 const fileList = (
703 <Box flexDirection="column" width={leftWidth} flexShrink={0}>
704 {treePages > 1 && (
705 <Box flexDirection="row" gap={1} alignItems="center">
706 {treeStart > 0 && (
707 <Button key="tree-prev" plain onPress={() => void turnTreePage($, -1)}>
708 ◀
709 </Button>
710 )}
711 <Text dimColor>{`${treeStart + 1}–${treeStart + treeShown.length} of ${tree.length}`}</Text>
712 {treeStart + TREE_ROWS < tree.length && (
713 <Button key="tree-next" plain onPress={() => void turnTreePage($, 1)}>
714 ▶
715 </Button>
716 )}
717 </Box>
718 )}
719 {treeShown.map(row => {
720 if (row.kind === 'dir') {
721 return (
722 <Box paddingLeft={row.depth * 2}>
723 <Button key={`dir:${row.path}`} plain dimColor onPress={() => void toggleDir($, row.path)}>
724 {`${row.isCollapsed ? '▸' : '▾'} ${row.name}`}
725 </Button>
726 </Box>
727 )
728 }
729 const letter = statusLetter(row.change.status)
730 const isOn = row.change.path === selected
731 return (
732 <Box flexDirection="row" gap={1} paddingLeft={row.depth * 2}>
733 <Text color={STATUS_COLOR[letter] ?? 'gray'} bold>
734 {letter}
735 </Text>
736 <Button key={`file:${row.change.path}`} plain dimColor={!isOn} onPress={() => void showFile($, row.change.path)}>
737 {row.name}
738 </Button>
739 </Box>
740 )
741 })}
742 </Box>
743 )
744
745 const changed = view.kind === 'hunks' ? view.hunks.join('\n').split('\n') : []
746 const added = changed.filter(line => line.startsWith('+')).length
747 const removed = changed.filter(line => line.startsWith('-')).length
748 const looks = LOOKS[colorTheme]
749
750 // Terminal: Claude Code's own diff view (line numbers, red/green rows,
751 // word highlights, in the theme). Desktop: the pane's own rows (see LOOKS).
752 const codeDiff = (hunks: readonly string[]) => (
753 <Box flexDirection="column" gap={1}>
754 {toBlocks(hunks).map(source => (
755 <Code format="diff" source={source} />
756 ))}
757 </Box>
758 )
759
760 const rowDiff = (hunks: readonly string[]) => {
761 const rows = toRows(hunks)
762 const gutter = String(Math.max(0, ...rows.map(row => ('lineNo' in row ? row.lineNo : 0)))).length
763 // A big tree stops the desktop from applying redraws (its controls go
764 // dead), so rows are kept to a few elements each and drawn a page at a time.
765 // The desktop stops applying redraws past a total tree size (~300 rows
766 // here), so the diff is drawn as a fixed window of PAGE_ROWS rows.
767 const start = Math.min(page * PAGE_ROWS, Math.max(0, rows.length - 1))
768 const shown = rows.slice(start, start + PAGE_ROWS)
769 const pageCount = Math.ceil(rows.length / PAGE_ROWS)
770 const pager = (where: string) =>
771 pageCount > 1 ? (
772 <Box flexDirection="row" gap={2} alignItems="center">
773 {page > 0 && (
774 <Button key={`prev-${where}`} onPress={() => void turnPage($, -1)}>
775 ◀ Previous
776 </Button>
777 )}
778 <Text dimColor>{`Rows ${start + 1}–${start + shown.length} of ${rows.length}`}</Text>
779 {page < pageCount - 1 && (
780 <Button key={`next-${where}`} onPress={() => void turnPage($, 1)}>
781 Next ▶
782 </Button>
783 )}
784 </Box>
785 ) : null
786 const drawRow = (row: (typeof shown)[number]) => {
787 if (row.kind === 'hunk') {
788 return (
789 <Box marginTop={1} paddingLeft={gutter + 3}>
790 <Text dimColor wrap="truncate-end">
791 {`⋯ ${row.text}`}
792 </Text>
793 </Box>
794 )
795 }
796 const look = looks[row.kind]
797 const text = (seg: { text: string }) => seg.text.replace(/\t/g, ' ') || ' '
798 const hasWords = row.segments.some(seg => seg.isChanged)
799 return (
800 <Box flexDirection="row" backgroundColor={look.background}>
801 {/* One fixed-width cell for number and marker keeps them aligned. */}
802 <Box width={gutter + 3} flexShrink={0}>
803 <Text color={look.number} dimColor={row.kind === 'context'}>
804 {`${String(row.lineNo).padStart(gutter)} ${look.mark}`}
805 </Text>
806 </Box>
807 <Text color={look.text}>
808 {hasWords
809 ? row.segments.map(seg => (
810 <Text backgroundColor={seg.isChanged ? look.word : undefined}>{text(seg)}</Text>
811 ))
812 : row.segments.map(text).join('')}
813 </Text>
814 </Box>
815 )
816 }
817 return (
818 <Box flexDirection="column" gap={1}>
819 {pager('top')}
820 <Box flexDirection="column">{shown.map(drawRow)}</Box>
821 {pager('bottom')}
822 </Box>
823 )
824 }
825
826 const diffBody =
827 view.kind === 'hunks' ? (
828 <Box flexDirection="column" gap={1}>
829 {e.surface === 'terminal' ? codeDiff(view.hunks) : rowDiff(view.hunks)}
830 {view.isCut && <Text dimColor>Diff truncated: too large to show in full.</Text>}
831 </Box>
832 ) : view.kind === 'text' ? (
833 <Text dimColor>{view.text}</Text>
834 ) : (
835 <Text dimColor>Pick a file on the left.</Text>
836 )
837
838 return (
839 <Box flexDirection="column" gap={1}>
840 {header}
841 {problem ? <Text color="red">{problem}</Text> : null}
842 {!problem && repoList.length > 0 && list.length === 0 ? (
843 <Text dimColor>
844 {modeInfo.empty} in {active?.name ?? baseName(where)}.
845 </Text>
846 ) : null}
847 {list.length > 0 && (
848 <Box flexDirection={isWide ? 'row' : 'column'} gap={2}>
849 {isListHidden ? null : fileList}
850 {/* Boxes have no one-sided border, so a 1-cell bar, stretched to
851 the row's height, stands in for a right border on the tree. */}
852 {isListHidden || !isWide ? null : (
853 <Box width={1} flexShrink={0} backgroundColor={SEPARATOR[colorTheme]} />
854 )}
855 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
856 <Box flexDirection="row" gap={1} marginBottom={1}>
857 <Text bold wrap="truncate-middle">
858 {selected || ' '}
859 </Text>
860 {added > 0 && <Text color={looks.add.marker}>{`+${added}`}</Text>}
861 {removed > 0 && <Text color={looks.del.marker}>{`-${removed}`}</Text>}
862 </Box>
863 {diffBody}
864 </Box>
865 </Box>
866 )}
867 </Box>
868 )
869 })
870}
871hooks/diff.ts 121 lines1// Turns unified-diff hunks into rows the pane draws: one per line, with its
2// line number and, for edited lines, which words changed.
3
4export type Segment = { text: string; isChanged: boolean }
5
6export type DiffRow =
7 | { kind: 'hunk'; text: string }
8 | { kind: 'context' | 'add' | 'del'; lineNo: number; segments: Segment[] }
9
10const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/
11
12// Lines longer than this in tokens are not compared word by word.
13const MAX_TOKENS = 400
14
15export function toRows(hunks: readonly string[]): DiffRow[] {
16 const rows: DiffRow[] = []
17 for (const hunk of hunks) {
18 const lines = hunk.split('\n')
19 const header = HUNK_HEADER.exec(lines[0])
20 let oldNo = header ? Number(header[1]) : 0
21 let newNo = header ? Number(header[2]) : 0
22 rows.push({ kind: 'hunk', text: header?.[3].trim() ?? '' })
23
24 let dels: string[] = []
25 let adds: string[] = []
26 const flush = () => {
27 // Pair the n-th removed line with the n-th added one for word highlights.
28 const pairs = Array.from({ length: Math.min(dels.length, adds.length) }, (_, i) => wordDiff(dels[i], adds[i]))
29 dels.forEach((text, i) => {
30 rows.push({ kind: 'del', lineNo: oldNo++, segments: pairs[i]?.[0] ?? [{ text, isChanged: false }] })
31 })
32 adds.forEach((text, i) => {
33 rows.push({ kind: 'add', lineNo: newNo++, segments: pairs[i]?.[1] ?? [{ text, isChanged: false }] })
34 })
35 dels = []
36 adds = []
37 }
38
39 for (const line of lines.slice(1)) {
40 if (line.startsWith('\\')) continue // "\ No newline at end of file"
41 const mark = line[0]
42 const text = line.slice(1)
43 if (mark === '-') {
44 if (adds.length) flush()
45 dels.push(text)
46 } else if (mark === '+') {
47 adds.push(text)
48 } else {
49 flush()
50 rows.push({ kind: 'context', lineNo: newNo, segments: [{ text, isChanged: false }] })
51 oldNo++
52 newNo++
53 }
54 }
55 flush()
56 }
57 return rows
58}
59
60const tokenize = (s: string) => s.match(/\w+|\s+|[^\w\s]/g) ?? []
61
62/**
63 * Word-level diff of an edited line pair. Returns the old line's and the new
64 * line's segments, changed tokens marked. Lines with little in common are
65 * left unmarked, as a highlight there is just noise.
66 */
67export function wordDiff(before: string, after: string): [Segment[], Segment[]] {
68 const plain: [Segment[], Segment[]] = [
69 [{ text: before, isChanged: false }],
70 [{ text: after, isChanged: false }],
71 ]
72 const a = tokenize(before)
73 const b = tokenize(after)
74 if (a.length > MAX_TOKENS || b.length > MAX_TOKENS) return plain
75
76 // Longest common subsequence over tokens.
77 const lcs: number[][] = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0))
78 for (let i = a.length - 1; i >= 0; i--) {
79 for (let j = b.length - 1; j >= 0; j--) {
80 lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1])
81 }
82 }
83 const common = lcs[0][0]
84 if (common === 0 || common / Math.max(a.length, b.length) < 0.3) return plain
85
86 const keepA = new Array(a.length).fill(false)
87 const keepB = new Array(b.length).fill(false)
88 for (let i = 0, j = 0; i < a.length && j < b.length; ) {
89 if (a[i] === b[j]) {
90 keepA[i++] = true
91 keepB[j++] = true
92 } else if (lcs[i + 1][j] >= lcs[i][j + 1]) i++
93 else j++
94 }
95 return [merge(a, keepA), merge(b, keepB)]
96}
97
98// An unchanged gap this short between two changed runs is folded into one
99// highlight (`foo(a, b)` → `bar(x, y)` reads as one change, not five).
100const MAX_GAP_CHARS = 3
101
102function merge(tokens: string[], kept: boolean[]): Segment[] {
103 const runs: Segment[] = []
104 tokens.forEach((text, i) => {
105 const isChanged = !kept[i]
106 const last = runs[runs.length - 1]
107 if (last && last.isChanged === isChanged) last.text += text
108 else runs.push({ text, isChanged })
109 })
110 const out: Segment[] = []
111 for (let i = 0; i < runs.length; i++) {
112 const run = runs[i]
113 const prev = out[out.length - 1]
114 const next = runs[i + 1]
115 const isShortGap = !run.isChanged && run.text.length <= MAX_GAP_CHARS && prev?.isChanged && next?.isChanged
116 if (isShortGap || (run.isChanged && prev?.isChanged)) prev!.text += run.text
117 else out.push({ ...run })
118 }
119 return out
120}
121hooks/parse.ts 253 lines1// Pure helpers for git output: no calls to Claude Code here. (The plugin
2// validator follows `$` only within one file, so everything that runs git
3// lives in register.tsx.)
4
5import type { DiffMode, DiffView, FileChange, Worktree } from '../types'
6
7// Caps that keep a huge or minified file from swamping the pane.
8const MAX_LINE_CHARS = 2000
9const MAX_DIFF_CHARS = 400000
10
11export const DIFF = ['diff', '--no-color', '--no-ext-diff', '-M']
12
13// --- paths -----------------------------------------------------------------
14// Paths are kept with forward slashes everywhere. Claude Code reports Windows
15// folders as C:\Users\…, git as C:/Users/…, and Windows ignores case, so paths
16// are normalized on the way in and compared with samePath / isInside.
17
18/** Forward slashes, no trailing slash (a bare root like `/` or `C:/` keeps it). */
19export const toSlash = (p: string) => p.replace(/\\/g, '/').replace(/(?<!^|:)\/+$/, '')
20
21/** A Windows drive path such as `C:/x` or `c:\x`. */
22export const isWindowsPath = (p: string) => /^[A-Za-z]:[\\/]/.test(p)
23
24/** An absolute path on either system. */
25export const isAbsolute = (p: string) => p.startsWith('/') || p.startsWith('\\') || isWindowsPath(p)
26
27const comparable = (p: string) => (isWindowsPath(p) ? toSlash(p).toLowerCase() : toSlash(p))
28
29/** Whether two paths name the same place: separators ignored, case too on Windows. */
30export const samePath = (a: string, b: string) => comparable(a) === comparable(b)
31
32/** Whether `path` is inside `dir` (not `dir` itself). */
33export const isInside = (path: string, dir: string) => comparable(path).startsWith(`${comparable(dir)}/`)
34
35/** `path` relative to `dir`, assuming isInside(path, dir). */
36export const relativeTo = (path: string, dir: string) => toSlash(path).slice(toSlash(dir).length + 1)
37
38export const join = (dir: string, name: string) => {
39 const base = toSlash(dir)
40 return base.endsWith('/') ? base + name : `${base}/${name}`
41}
42export const baseName = (p: string) => toSlash(p).split('/').pop() || p
43
44/** A file name safe on every system: Windows forbids <>:"/\|?* and control characters. */
45export const safeName = (name: string) => name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
46
47export function parseStatus(out: string): FileChange[] {
48 const parts = out.split('\0')
49 const list: FileChange[] = []
50 for (let i = 0; i < parts.length; i++) {
51 const entry = parts[i]
52 if (entry.length < 4) continue
53 const status = entry.slice(0, 2)
54 const path = entry.slice(3)
55 if (status.includes('R') || status.includes('C')) list.push({ status, path, from: parts[++i] })
56 else list.push({ status, path })
57 }
58 return list
59}
60
61/** `git diff --name-status -z`: `M\0path`, or `R100\0old\0new` for a rename. */
62export function parseNameStatus(out: string): FileChange[] {
63 const parts = out.split('\0').filter(Boolean)
64 const list: FileChange[] = []
65 for (let i = 0; i < parts.length; i++) {
66 const letter = parts[i][0]
67 if (letter === 'R' || letter === 'C') {
68 list.push({ status: `${letter} `, from: parts[i + 1], path: parts[i + 2] })
69 i += 2
70 } else {
71 list.push({ status: `${letter} `, path: parts[++i] })
72 }
73 }
74 return list
75}
76
77export const untracked = (out: string): FileChange[] =>
78 out
79 .split('\0')
80 .filter(Boolean)
81 .map(path => ({ status: '??', path }))
82
83/** Splits `git diff` output into its hunks, each starting at its `@@` header. */
84export function toView(text: string): DiffView {
85 if (!text.trim()) return { kind: 'text', text: 'No textual changes (mode change or empty file).' }
86 if (/^Binary files .* differ$/m.test(text) && !text.includes('\n@@')) {
87 return { kind: 'text', text: 'Binary file changed.' }
88 }
89 const isCut = text.length > MAX_DIFF_CHARS
90 const lines = (isCut ? text.slice(0, MAX_DIFF_CHARS) : text).split('\n')
91 const hunks: string[] = []
92 let current: string[] = []
93 const flush = () => {
94 if (current.length > 1) hunks.push(current.join('\n'))
95 current = []
96 }
97 for (const raw of lines) {
98 const line = raw.slice(0, MAX_LINE_CHARS)
99 if (line.startsWith('@@')) {
100 flush()
101 current = [line]
102 } else if (line.startsWith('diff --git')) {
103 flush()
104 } else if (current.length) {
105 current.push(line)
106 }
107 }
108 flush()
109 if (hunks.length === 0) return { kind: 'text', text: text.slice(0, MAX_LINE_CHARS) }
110 return { kind: 'hunks', hunks, isCut }
111}
112
113
114// --- file tree -------------------------------------------------------------
115
116export type TreeRow =
117 | { kind: 'dir'; path: string; name: string; depth: number; isCollapsed: boolean }
118 | { kind: 'file'; change: FileChange; name: string; depth: number }
119
120type Node = { dirs: Map<string, Node>; files: FileChange[] }
121
122/** Folders first, then files, each sorted by name; a folded folder hides its contents. */
123export function toTree(changes: readonly FileChange[], collapsed: ReadonlySet<string>): TreeRow[] {
124 const top: Node = { dirs: new Map(), files: [] }
125 for (const change of changes) {
126 const parts = change.path.split('/')
127 let node = top
128 for (const part of parts.slice(0, -1)) {
129 if (!node.dirs.has(part)) node.dirs.set(part, { dirs: new Map(), files: [] })
130 node = node.dirs.get(part)!
131 }
132 node.files.push(change)
133 }
134 const rows: TreeRow[] = []
135 const walk = (node: Node, prefix: string, depth: number) => {
136 for (const [name, child] of [...node.dirs].sort(([a], [b]) => a.localeCompare(b))) {
137 const path = prefix + name
138 const isCollapsed = collapsed.has(path)
139 rows.push({ kind: 'dir', path, name, depth, isCollapsed })
140 if (!isCollapsed) walk(child, `${path}/`, depth + 1)
141 }
142 for (const change of [...node.files].sort((a, b) => a.path.localeCompare(b.path))) {
143 rows.push({ kind: 'file', change, name: baseName(change.path), depth })
144 }
145 }
146 walk(top, '', 0)
147 return rows
148}
149
150// --- Code blocks -----------------------------------------------------------
151
152// A Code element takes at most 10,000 characters.
153const MAX_BLOCK_CHARS = 9500
154
155const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/
156
157/**
158 * Packs hunks into as few `Code format="diff"` sources as fit the size limit.
159 * A hunk too big on its own is cut into smaller hunks with recounted headers,
160 * so each piece still parses as a diff.
161 */
162export function toBlocks(hunks: readonly string[]): string[] {
163 const pieces = hunks.flatMap(splitHunk)
164 const blocks: string[] = []
165 let current = ''
166 for (const piece of pieces) {
167 if (current && current.length + piece.length + 1 > MAX_BLOCK_CHARS) {
168 blocks.push(current)
169 current = ''
170 }
171 current = current ? `${current}\n${piece}` : piece
172 }
173 if (current) blocks.push(current)
174 return blocks
175}
176
177function splitHunk(hunk: string): string[] {
178 const [head, ...rest] = hunk.split('\n')
179 // Drop trailing blank lines; a blank context line is a single space.
180 while (rest.length && rest[rest.length - 1] === '') rest.pop()
181 const body = rest.map(line => (line === '' ? ' ' : line))
182 if (hunk.length <= MAX_BLOCK_CHARS) return [[head, ...body].join('\n')]
183
184 const match = HUNK_HEADER.exec(head)
185 let oldNo = match ? Number(match[1]) : 1
186 let newNo = match ? Number(match[2]) : 1
187 const pieces: string[] = []
188 let lines: string[] = []
189 let size = 0
190 let oldCount = 0
191 let newCount = 0
192 let startOld = oldNo
193 let startNew = newNo
194 const flush = () => {
195 if (lines.length) pieces.push([`@@ -${startOld},${oldCount} +${startNew},${newCount} @@`, ...lines].join('\n'))
196 lines = []
197 size = 0
198 oldCount = 0
199 newCount = 0
200 startOld = oldNo
201 startNew = newNo
202 }
203 for (const line of body) {
204 if (size + line.length + 1 > MAX_BLOCK_CHARS - 100) flush()
205 lines.push(line)
206 size += line.length + 1
207 if (line.startsWith('-')) {
208 oldCount++
209 oldNo++
210 } else if (line.startsWith('+')) {
211 newCount++
212 newNo++
213 } else if (!line.startsWith('\\')) {
214 oldCount++
215 newCount++
216 oldNo++
217 newNo++
218 }
219 }
220 flush()
221 return pieces
222}
223
224// --- worktrees -------------------------------------------------------------
225
226/** `git worktree list --porcelain`: blocks of `worktree <path>`, `HEAD <sha>`, `branch <ref>` or `detached`. */
227export function parseWorktrees(out: string): Worktree[] {
228 return out
229 .split(/\r?\n\r?\n/)
230 .map(block => {
231 const lines = block.split(/\r?\n/)
232 const value = (field: string) => lines.find(line => line.startsWith(field + ' '))?.slice(field.length + 1)
233 const raw = value('worktree')
234 const path = raw ? toSlash(raw) : undefined
235 const ref = value('branch')
236 return path ? { path, sha: (value('HEAD') ?? '').slice(0, 7), branch: ref ? ref.replace(/^refs\/heads\//, '') : null } : null
237 })
238 .filter((one): one is Worktree => one !== null)
239}
240
241/**
242 * A short, stable name for a folder (8 hex characters, FNV-1a of the path as
243 * samePath compares it), so each checkout of a repo gets its own snapshot ref.
244 */
245export function folderHash(path: string) {
246 let hash = 0x811c9dc5
247 for (const char of comparable(path)) {
248 hash ^= char.codePointAt(0) ?? 0
249 hash = Math.imul(hash, 0x01000193) >>> 0
250 }
251 return hash.toString(16).padStart(8, '0')
252}
253types/index.d.ts 64 lines1export type Repo = { name: string; path: string }
2
3/** One checkout of a repo: its main working tree or a linked worktree. */
4export type Worktree = { path: string; sha: string; /** null when detached */ branch: string | null }
5
6export type FileChange = {
7 /** Relative to the repo root. */
8 path: string
9 /** The original path of a rename or copy. */
10 from?: string
11 /** Two-letter status, e.g. ` M`, `A `, `??`. */
12 status: string
13 /** Session mode: the snapshot tree from the session's start, and the tree of the files now. */
14 trees?: { base: string; now: string }
15}
16
17/**
18 * What a diff compares:
19 * - head: working tree (staged + unstaged) against the last commit
20 * - unstaged: working tree against the staging area
21 * - staged: staging area against the last commit
22 * - base: the branch's commits against where it left the default branch (a PR's view)
23 * - session: every change since this session started (by Claude or anyone), against a git snapshot taken then
24 */
25export type DiffMode = 'head' | 'unstaged' | 'staged' | 'base' | 'session'
26
27export type Theme = 'light' | 'dark'
28
29export type DiffView =
30 | { kind: 'empty' }
31 | { kind: 'hunks'; hunks: string[]; isCut: boolean }
32 | { kind: 'text'; text: string }
33
34declare module 'claude-code' {
35 interface PluginState {
36 'multirepo-diff-mod': {
37 root: string
38 repos: Repo[]
39 repo: string
40 worktrees: Worktree[]
41 /** The checkout being compared: the repo's main path or one of its worktrees. */
42 worktree: string
43 /** Whether the worktree picker lists detached worktrees. */
44 showDetached: boolean
45 mode: DiffMode
46 /** A line under the header explaining what is compared, e.g. the base branch. */
47 note: string
48 files: FileChange[]
49 file: string
50 diff: DiffView
51 error: string
52 theme: Theme
53 /** Desktop: which page of PAGE_ROWS diff rows is shown, from 0. */
54 diffPage: number
55 /** Which page of TREE_ROWS file-tree rows is shown, from 0. */
56 treePage: number
57 /** True while the file list is folded away. */
58 isListCollapsed: boolean
59 /** Folders folded in the file tree, by path. */
60 collapsedDirs: string[]
61 }
62 }
63}
64