Git Diff timeline above the prompt: click a commit to see it, click two to compare them, with their commit messages; a Branches tab compares two branches; a…

A Claude Code mod that puts a Git Diff timeline above the prompt.
base...compare): commits ahead and behind, where they split, and what the compare branch adds.It reads local git only, so it works with GitHub, GitLab or any other remote.
Load the folder as a plugin directory, for every session, through the env block of ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/git-diff-timeline",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}
}
Or for one session: claude --plugin-dir /path/to/git-diff-timeline.
/gitdiff opens the files view. The position option (band or pane) chooses between the strip above the prompt and a side pane.
claude plugin test .
claude plugin validate .hooks/register.tsx 848 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register, RenderElement, Timer } from 'claude-code'
3
4import type { GitDiffCommit, GitDiffFile, GitDiffPatch, GitDiffStat, GitDiffTimeline } from '../types'
5import { branchCard, historyCard } from './card'
6import type { CardNode } from './card'
7import { EMPTY_TREE, LOG_LIMIT, loadBranchCompare, loadPatch, loadStat, loadTimeline } from './git'
8import type { Loaded, Run } from './git'
9import { ARROW_W, SLOT_W, dayLabels, formatTime, resolveStart, shortDate, stepStart, visibleCount } from './layout'
10import {
11 INITIAL,
12 WORKING,
13 buttonLabel,
14 clickCommit,
15 commitTitle,
16 describeStat,
17 diffSpec,
18 merge,
19 nodeIds,
20 rangeCommits,
21 refOf,
22 selectionKey,
23} from './model'
24
25type Kit = Pick<ElementTable<'terminal'>, 'Box' | 'Text' | 'Button' | 'Code'> & {
26 /** Absent on the phone. */
27 Select: ElementTable<'terminal'>['Select'] | null
28 /** The desktop's; the terminal draws the timeline in text. */
29 Svg: ElementTable<'desktop'>['Svg'] | null
30}
31
32type Actions = {
33 refresh: () => void
34 details: () => void
35 mode: (mode: GitDiffTimeline['mode']) => void
36 pickCommit: (index: number) => void
37 pickBranch: (side: 'base' | 'compare', name: string) => void
38 swap: () => void
39 step: (delta: number, count: number) => void
40 toggleFile: (path: string) => void
41}
42
43const PANE = 'git-diff'
44const PANE_ARGS = { id: PANE, title: 'Git Diff', rows: 24, columns: 96 } as const
45
46/** Tools whose calls can change what git sees. */
47const WATCHED = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash'])
48const REFRESH_DELAY_MS = 1200
49/** Files the files view lists, and diffs it holds open at once. */
50const MAX_FILES = 100
51const MAX_OPEN = 4
52/** Commits the files view lists above the files. */
53const MAX_COMMITS = 20
54const BLOCKS = 5
55
56const GRAY = '#9aa3b2'
57const GREEN = '#2f9e5b'
58const RED = '#d9534f'
59const ACCENT = '#ff4d2e'
60
61const timeline = atom({ plugin: 'git-diff-timeline', key: 'timeline' } as const, INITIAL)
62
63/** `band`: the strip above the prompt. `pane`: the side pane, opened on its own. Set in register. */
64let position: 'band' | 'pane' = 'band'
65
66const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error))
67
68const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
69
70const kitOf = (ui: ElementTable): Kit => ({
71 Box: ui.Box,
72 Text: ui.Text,
73 Button: ui.Button,
74 Code: ui.Code,
75 Select: 'Select' in ui ? ui.Select : null,
76 Svg: 'Svg' in ui ? ui.Svg : null,
77})
78
79const runnerFor = async ($: EngineInterface): Promise<Run> => {
80 const cwd = await $.session.cwd()
81
82 return argv => $.process.run(argv, { cwd, timeoutMs: 15_000 })
83}
84
85/** The files open after a fresh read: the first file for a new pick, else those still changed. */
86const openAfter = (cur: GitDiffTimeline, key: string, stat: GitDiffStat | null): string[] => {
87 if (cur.statKey !== key) {
88 const first = stat?.files[0]
89
90 return first === undefined ? [] : [first.path]
91 }
92
93 const paths = new Set(stat?.files.map(file => file.path) ?? [])
94
95 return cur.open.filter(path => paths.has(path))
96}
97
98/** Reads the diff of each open file still unread (`force`: every open file). */
99const loadPatches = async ($: EngineInterface, force: boolean) => {
100 const t = await read($, timeline)
101
102 if (t.status !== 'ready' || t.stat === null) {
103 return
104 }
105
106 const key = t.statKey
107 const wanted = t.open.filter(path => force || !t.patches.some(patch => patch.path === path))
108
109 if (wanted.length === 0) {
110 return
111 }
112
113 const run = await runnerFor($)
114 const spec = diffSpec(t)
115 const loaded = await Promise.all(wanted.map(path => loadPatch(run, spec, path)))
116
117 await update($, timeline, cur =>
118 cur.statKey !== key
119 ? cur
120 : {
121 ...cur,
122 patches: [...cur.patches.filter(patch => !wanted.includes(patch.path)), ...loaded].filter(patch =>
123 cur.open.includes(patch.path),
124 ),
125 },
126 )
127}
128
129/** Reads what the pick changed, unless it is on screen already (`force` rereads), then its open files. */
130const loadSelection = async ($: EngineInterface, force: boolean) => {
131 const t = await read($, timeline)
132
133 if (t.status !== 'ready') {
134 return
135 }
136
137 const key = selectionKey(t)
138
139 if (!force && t.statKey === key && t.stat !== null) {
140 await loadPatches($, false)
141
142 return
143 }
144
145 const run = await runnerFor($)
146 let stat: GitDiffStat | null = null
147 let statError = ''
148 let branchCompare = t.branchCompare
149
150 try {
151 const [loadedStat, compared] = await Promise.all([
152 loadStat(run, diffSpec(t)),
153 t.mode === 'branches' ? loadBranchCompare(run, t.base, t.compare) : Promise.resolve(null),
154 ])
155
156 stat = loadedStat
157 branchCompare = compared ?? branchCompare
158 } catch (error) {
159 statError = errorText(error)
160 }
161
162 await update($, timeline, cur =>
163 selectionKey(cur) !== key
164 ? cur
165 : {
166 ...cur,
167 stat,
168 statKey: key,
169 statError,
170 branchCompare: cur.mode === 'branches' ? branchCompare : cur.branchCompare,
171 open: openAfter(cur, key, stat),
172 patches: cur.statKey === key ? cur.patches : [],
173 },
174 )
175 await loadPatches($, force)
176}
177
178const refresh = async ($: EngineInterface) => {
179 const cwd = await $.session.cwd()
180 const run = await runnerFor($)
181 const failed = (error: unknown): Loaded => ({
182 status: 'error',
183 message: errorText(error),
184 branch: '',
185 commits: [],
186 hasWorking: false,
187 untracked: 0,
188 baseOfOldest: '',
189 branches: [],
190 defaultBase: '',
191 })
192 const loaded = await loadTimeline(run).catch(failed)
193
194 await update($, timeline, prev => merge(prev, loaded, cwd))
195 await loadSelection($, true)
196}
197
198let pending: Timer | undefined
199
200/** Whether the pane has been seated this session; a pane opened unasked can wait undrawn. */
201let wasPlaced = false
202let hasOffered = false
203
204const openDetails = async ($: EngineInterface) => {
205 try {
206 wasPlaced = (await $.ui.open(PANE_ARGS)).isPlaced
207 } catch {
208 // /gitdiff says why when the pane cannot open.
209 }
210}
211
212/** Shows what the person picked: the files view opened by their press, then a fresh read. */
213const show = async ($: EngineInterface, change: (t: GitDiffTimeline) => GitDiffTimeline) => {
214 await openDetails($)
215 await update($, timeline, t => {
216 const next = change(t)
217
218 return selectionKey(next) === selectionKey(t)
219 ? next
220 : { ...next, stat: null, statKey: '', statError: '', open: [], patches: [] }
221 })
222 await loadSelection($, false)
223}
224
225const pickCommit = ($: EngineInterface, index: number) =>
226 show($, t => ({ ...t, ...clickCommit(t, index), isPinned: true }))
227
228const pickBranch = ($: EngineInterface, side: 'base' | 'compare', name: string) =>
229 show($, t => (side === 'base' ? { ...t, base: name } : { ...t, compare: name }))
230
231const toggleFile = async ($: EngineInterface, path: string) => {
232 await update($, timeline, t => {
233 if (t.open.includes(path)) {
234 return { ...t, open: t.open.filter(p => p !== path), patches: t.patches.filter(patch => patch.path !== path) }
235 }
236
237 const open = [...t.open, path].slice(-MAX_OPEN)
238
239 return { ...t, open, patches: t.patches.filter(patch => open.includes(patch.path)) }
240 })
241 await loadPatches($, false)
242}
243
244const step = ($: EngineInterface, delta: number, count: number) =>
245 update($, timeline, t => ({ ...t, start: stepStart(t.start, delta, count, nodeIds(t).length) }))
246
247const actionsFor = ($: EngineInterface): Actions => ({
248 refresh: () => void refresh($).catch(() => undefined),
249 details: () => void openDetails($),
250 mode: mode => void show($, t => ({ ...t, mode })),
251 pickCommit: index => void pickCommit($, index),
252 pickBranch: (side, name) => void pickBranch($, side, name),
253 swap: () => void show($, t => ({ ...t, base: t.compare, compare: t.base })),
254 step: (delta, count) => void step($, delta, count),
255 toggleFile: path => void toggleFile($, path),
256})
257
258/** A node in words: its pull request or message, `Uncommitted changes`, or what came before. */
259const nodeName = (t: GitDiffTimeline, index: number) => {
260 if (index < 0) {
261 const oldest = t.commits[0]
262
263 return t.baseOfOldest === EMPTY_TREE.sha1 || t.baseOfOldest === EMPTY_TREE.sha256 || oldest === undefined
264 ? 'nothing (before the first commit)'
265 : `before ${commitTitle(oldest)}`
266 }
267
268 const commit = t.commits[index]
269
270 return commit === undefined ? 'Uncommitted changes' : commitTitle(commit)
271}
272
273const when = (seconds: number) => `${shortDate(seconds)}, ${formatTime(seconds)}`
274
275/** What a click does next: wait for the second commit, or start a comparison. */
276const clickHint = (t: GitDiffTimeline) => {
277 const waiting = nodeIds(t).indexOf(t.anchor)
278
279 return waiting < 0
280 ? 'Click a commit to see its changes · click two to compare them'
281 : `Click another commit to compare it with ${nodeName(t, waiting)}`
282}
283
284const rangeTitle = (t: GitDiffTimeline) =>
285 t.mode === 'branches' ? `${t.base} ← ${t.compare}` : `${nodeName(t, t.from)} → ${nodeName(t, t.to)}`
286
287const roleOf = (t: GitDiffTimeline, index: number): CardNode['role'] =>
288 index === t.to ? 'compare' : index === t.from ? 'base' : index > t.from && index < t.to ? 'between' : 'outside'
289
290/** The window of commits the strip shows: where it starts and how many. */
291const windowOf = (t: GitDiffTimeline, columns: number) => {
292 const total = nodeIds(t).length
293 const count = visibleCount(columns, total)
294 const start = resolveStart(t.start, count, total)
295
296 return { total, count, start, indexes: Array.from({ length: Math.min(count, total) }, (_, i) => start + i) }
297}
298
299const branchOptions = (t: GitDiffTimeline) =>
300 t.branches.map(branch => ({
301 value: branch.name,
302 label: branch.name === t.branch ? `${branch.name} (current)` : branch.name,
303 }))
304
305/** The strip's first row: History or Branches, how to pick, the files view and a refresh. */
306const controls = ({ Box, Text, Button, Select }: Kit, t: GitDiffTimeline, on: Actions): RenderElement => {
307 const isHistory = t.mode === 'history'
308 const pickers = isHistory ? (
309 <Text dimColor>{clickHint(t)}</Text>
310 ) : Select === null ? null : t.branches.length === 0 ? (
311 <Text dimColor>No branches to compare.</Text>
312 ) : (
313 <Box flexDirection="row" columnGap={1} alignItems="center" flexWrap="wrap">
314 <Select
315 key="branch-base"
316 label="Base"
317 options={branchOptions(t)}
318 value={t.base}
319 onSelect={name => on.pickBranch('base', name)}
320 />
321 <Text dimColor>←</Text>
322 <Select
323 key="branch-compare"
324 label="Compare"
325 options={branchOptions(t)}
326 value={t.compare}
327 onSelect={name => on.pickBranch('compare', name)}
328 />
329 <Button key="swap" plain label="⇄" onPress={on.swap} />
330 </Box>
331 )
332
333 return (
334 <Box flexDirection="row" flexWrap="wrap" columnGap={1} alignItems="center" justifyContent="space-between">
335 <Box flexDirection="row" columnGap={1} alignItems="center" flexWrap="wrap">
336 <Button
337 key="mode:history"
338 label="History"
339 {...(isHistory ? { variant: 'primary' as const } : {})}
340 onPress={() => on.mode('history')}
341 />
342 <Button
343 key="mode:branches"
344 label="Branches"
345 {...(isHistory ? {} : { variant: 'primary' as const })}
346 onPress={() => on.mode('branches')}
347 />
348 {pickers}
349 </Box>
350 <Box flexDirection="row" columnGap={1}>
351 <Button key="details" label="Files changed" onPress={on.details} />
352 <Button key="refresh" plain label="↻" onPress={on.refresh} />
353 </Box>
354 </Box>
355 )
356}
357
358/** The totals as text: the terminal's strip, and the files view's first line. */
359const totals = ({ Box, Text }: Kit, t: GitDiffTimeline): RenderElement => {
360 const stat = describeStat(t.stat)
361
362 return (
363 <Box flexDirection="row" columnGap={1}>
364 {t.statError !== '' && <Text color={RED}>{t.statError}</Text>}
365 {t.statError === '' && stat === null && <Text dimColor>Reading the diff…</Text>}
366 {stat !== null && <Text>{stat.files}</Text>}
367 {stat !== null && <Text color={GREEN}>{stat.added}</Text>}
368 {stat !== null && <Text color={RED}>{stat.deleted}</Text>}
369 </Box>
370 )
371}
372
373const dotColor = (role: CardNode['role']) =>
374 role === 'base' || role === 'compare' ? { color: ACCENT } : role === 'between' ? {} : { color: GRAY }
375
376/** History: the card (a line of dots in text on the terminal), and under it a button per commit. */
377const historyStrip = (kit: Kit, t: GitDiffTimeline, columns: number, on: Actions): RenderElement => {
378 const { Box, Text, Button, Svg } = kit
379 const { total, count, start, indexes } = windowOf(t, columns)
380 const labels = dayLabels(indexes.map(i => t.commits[i]?.time ?? null))
381 const nodes: CardNode[] = indexes.map((i, k) => {
382 const commit = t.commits[i]
383
384 return commit === undefined
385 ? { label: 'Now', churn: 0, role: roleOf(t, i), isWorking: true, tip: 'Uncommitted changes' }
386 : {
387 label: labels[k] ?? '',
388 churn: commit.added + commit.deleted,
389 role: roleOf(t, i),
390 isWorking: false,
391 tip: `${commit.short} · ${commitTitle(commit)} · +${commit.added} −${commit.deleted}`,
392 }
393 })
394 const title = `${t.branch} · ${plural(t.commits.length, 'commit')}${t.commits.length >= LOG_LIMIT ? ' shown' : ''}`
395 const picture =
396 Svg === null ? (
397 <Box flexDirection="column">
398 {totals(kit, t)}
399 <Box flexDirection="row">
400 <Box width={ARROW_W} />
401 {nodes.map(node => (
402 <Box width={SLOT_W} justifyContent="center">
403 <Text {...dotColor(node.role)} bold={node.role === 'compare'}>
404 {node.isWorking ? '○' : node.role === 'compare' ? '◉' : '●'}
405 </Text>
406 </Box>
407 ))}
408 </Box>
409 </Box>
410 ) : (
411 <Svg
412 source={historyCard({
413 columns,
414 nodes,
415 title,
416 stats: describeStat(t.stat),
417 older: start,
418 newer: total - start - indexes.length,
419 })}
420 alt={`${title}: ${rangeTitle(t)}`}
421 />
422 )
423
424 return (
425 <Box flexDirection="column">
426 {picture}
427 <Box flexDirection="row">
428 <Box width={ARROW_W}>
429 <Button key="prev" plain dimColor={start === 0} label="‹" onPress={() => on.step(-1, count)} />
430 </Box>
431 {indexes.map(i => {
432 const commit = t.commits[i]
433 const role = roleOf(t, i)
434 const look =
435 role === 'compare'
436 ? { variant: 'primary' as const }
437 : role === 'base'
438 ? { variant: 'secondary' as const }
439 : { plain: true as const, dimColor: role === 'outside' }
440
441 return (
442 <Box width={SLOT_W} justifyContent="center">
443 <Button
444 key={`node:${i}`}
445 label={commit === undefined ? 'Now' : buttonLabel(commit)}
446 {...look}
447 onPress={() => on.pickCommit(i)}
448 />
449 </Box>
450 )
451 })}
452 <Box width={ARROW_W} justifyContent="flex-end">
453 <Button key="next" plain dimColor={start + count >= total} label="›" onPress={() => on.step(1, count)} />
454 </Box>
455 </Box>
456 {messageRow(kit, t, t.from, '○')}
457 {messageRow(kit, t, t.to, '●')}
458 </Box>
459 )
460}
461
462/** One end of the comparison: its mark, short sha, message and when. */
463const messageRow = ({ Box, Text }: Kit, t: GitDiffTimeline, index: number, mark: string): RenderElement => {
464 const commit = t.commits[index]
465 const short = index < 0 ? t.baseOfOldest.slice(0, 7) : (commit?.short ?? '')
466
467 return (
468 <Box flexDirection="row" columnGap={1}>
469 <Text color={ACCENT}>{mark}</Text>
470 {short !== '' && <Text dimColor>{short}</Text>}
471 <Box flexShrink={1}>
472 <Text wrap="truncate-end">{nodeName(t, index)}</Text>
473 </Box>
474 {commit !== undefined && <Text dimColor>{when(commit.time)}</Text>}
475 </Box>
476 )
477}
478
479/** Branches: the fork card, or a line of words on the terminal. */
480const branchStrip = (kit: Kit, t: GitDiffTimeline, columns: number): RenderElement => {
481 const { Box, Text, Svg } = kit
482 const compared = t.branchCompare
483 const ahead = compared?.ahead ?? 0
484 const behind = compared?.behind ?? 0
485 const words = `${t.compare} is ${plural(ahead, 'commit')} ahead, ${behind} behind ${t.base}`
486
487 if (Svg === null) {
488 return (
489 <Box flexDirection="column">
490 <Text>{words}</Text>
491 {totals(kit, t)}
492 </Box>
493 )
494 }
495
496 const mergeBase = compared?.mergeBase ?? null
497
498 return (
499 <Svg
500 source={branchCard({
501 columns,
502 base: t.base,
503 compare: t.compare,
504 ahead,
505 behind,
506 aheadTips: compared?.aheadCommits.map(commitTitle) ?? [],
507 behindTips: compared?.behindCommits.map(commitTitle) ?? [],
508 mergeBase: mergeBase === null ? '' : `merge base ${mergeBase.short} · ${shortDate(mergeBase.time)}`,
509 stats: describeStat(t.stat),
510 })}
511 alt={words}
512 />
513 )
514}
515
516const strip = (kit: Kit, t: GitDiffTimeline, columns: number, on: Actions): RenderElement => {
517 const { Box } = kit
518
519 return (
520 <Box flexDirection="column">
521 {controls(kit, t, on)}
522 {t.mode === 'history' ? historyStrip(kit, t, columns, on) : branchStrip(kit, t, columns)}
523 </Box>
524 )
525}
526
527/** GitHub's five squares: a file's share of lines added and deleted. */
528const blocks = (file: GitDiffFile) => {
529 const total = (file.added ?? 0) + (file.deleted ?? 0)
530
531 if (file.added === null || total === 0) {
532 return { added: 0, deleted: 0 }
533 }
534
535 const added = Math.round((file.added / total) * BLOCKS)
536
537 return { added, deleted: BLOCKS - added }
538}
539
540const patchView = ({ Box, Text, Code }: Kit, path: string, patch: GitDiffPatch | undefined): RenderElement => {
541 if (patch === undefined) {
542 return <Text dimColor>Loading the diff…</Text>
543 }
544
545 if (patch.note === 'binary') {
546 return <Text dimColor>Binary file not shown.</Text>
547 }
548
549 if (patch.note === 'empty') {
550 return <Text dimColor>No line changes: the file's mode or name changed.</Text>
551 }
552
553 if (patch.note !== '') {
554 return <Text color={RED}>{patch.note}</Text>
555 }
556
557 return (
558 <Box flexDirection="column">
559 <Code source={patch.hunks} format="diff" path={path} />
560 {patch.isCut && <Text dimColor>Diff cut to fit here; open the file to see the rest.</Text>}
561 </Box>
562 )
563}
564
565const fileRow = (kit: Kit, t: GitDiffTimeline, file: GitDiffFile, on: Actions): RenderElement => {
566 const { Box, Text, Button } = kit
567 const isOpen = t.open.includes(file.path)
568 const share = blocks(file)
569 const rest = BLOCKS - share.added - share.deleted
570
571 return (
572 <Box flexDirection="column">
573 <Box flexDirection="row" columnGap={1}>
574 <Button
575 key={`file:${file.path}`}
576 plain
577 label={`${isOpen ? '▾' : '▸'} ${file.path}`}
578 onPress={() => on.toggleFile(file.path)}
579 />
580 <Text color={GREEN}>{file.added === null ? 'binary' : `+${file.added}`}</Text>
581 {file.deleted !== null && <Text color={RED}>{`−${file.deleted}`}</Text>}
582 <Box flexDirection="row">
583 {share.added > 0 && <Text color={GREEN}>{'■'.repeat(share.added)}</Text>}
584 {share.deleted > 0 && <Text color={RED}>{'■'.repeat(share.deleted)}</Text>}
585 {rest > 0 && <Text dimColor>{'■'.repeat(rest)}</Text>}
586 </Box>
587 </Box>
588 {isOpen && patchView(kit, file.path, t.patches.find(patch => patch.path === file.path))}
589 </Box>
590 )
591}
592
593const notice = ({ Text }: Kit, t: GitDiffTimeline): RenderElement => {
594 switch (t.status) {
595 case 'loading':
596 return <Text dimColor>Reading git history…</Text>
597 case 'no-repo':
598 return <Text dimColor>{`Not a git repository: ${t.cwd}`}</Text>
599 case 'error':
600 return <Text color={RED}>{`git failed: ${t.message}`}</Text>
601 default:
602 return <Text dimColor>{t.message}</Text>
603 }
604}
605
606/** One commit of a comparison: short sha, message and when, and the body's first line under it. */
607const commitRow = ({ Box, Text }: Kit, commit: GitDiffCommit): RenderElement => {
608 const title = commitTitle(commit)
609 const detail = commit.body
610 .split('\n')
611 .map(line => line.trim())
612 .find(line => line !== '' && !title.includes(line) && !line.startsWith('See merge request'))
613
614 return (
615 <Box flexDirection="column">
616 <Box flexDirection="row" columnGap={1}>
617 <Text dimColor>{commit.short}</Text>
618 <Box flexShrink={1}>
619 <Text wrap="truncate-end">{title}</Text>
620 </Box>
621 <Text dimColor>{when(commit.time)}</Text>
622 </Box>
623 {detail !== undefined && (
624 <Box paddingLeft={8}>
625 <Text dimColor wrap="truncate-end">
626 {detail}
627 </Text>
628 </Box>
629 )}
630 </Box>
631 )
632}
633
634/** The commits the comparison takes in, newest first, as GitHub's compare page lists them. */
635const commitList = (kit: Kit, t: GitDiffTimeline): RenderElement | null => {
636 const { Box, Text } = kit
637 const commits =
638 t.mode === 'history' ? rangeCommits(t) : [...(t.branchCompare?.aheadCommits ?? [])].reverse()
639 const total = t.mode === 'history' ? commits.length : (t.branchCompare?.ahead ?? commits.length)
640
641 if (commits.length === 0) {
642 return null
643 }
644
645 return (
646 <Box flexDirection="column">
647 <Text bold>{t.mode === 'history' ? plural(total, 'commit') : `${plural(total, 'commit')} on ${t.compare}`}</Text>
648 {commits.slice(0, MAX_COMMITS).map(commit => commitRow(kit, commit))}
649 {total > MAX_COMMITS && <Text dimColor>{`… ${total - MAX_COMMITS} older not listed`}</Text>}
650 </Box>
651 )
652}
653
654/** The files view: the commits compared, then what changed, file by file, as GitHub shows it. */
655const filesView = (kit: Kit, t: GitDiffTimeline, on: Actions): RenderElement => {
656 const { Box, Text } = kit
657 const files = t.stat?.files ?? []
658 const more = files.length - MAX_FILES
659 const compared = t.branchCompare
660
661 return (
662 <Box flexDirection="column" rowGap={1}>
663 {t.mode === 'branches' && compared !== null && (
664 <Text dimColor>
665 {`${t.compare} is ${plural(compared.ahead, 'commit')} ahead, ${compared.behind} behind ${t.base}` +
666 (compared.mergeBase === null ? ' · no shared history' : ` · they split at ${compared.mergeBase.short}`)}
667 </Text>
668 )}
669 {commitList(kit, t)}
670 {totals(kit, t)}
671 {files.slice(0, MAX_FILES).map(file => fileRow(kit, t, file, on))}
672 {more > 0 && <Text dimColor>{`… ${plural(more, 'more file')} not listed`}</Text>}
673 {t.mode === 'history' && refOf(t, t.to) === WORKING && t.untracked > 0 && (
674 <Text dimColor>{`${plural(t.untracked, 'untracked file')} not in this diff`}</Text>
675 )}
676 </Box>
677 )
678}
679
680const paneView = (kit: Kit, t: GitDiffTimeline, bodyColumns: number, on: Actions): RenderElement => {
681 const { Box, Text, Button } = kit
682 const isReady = t.status === 'ready'
683
684 return (
685 <Box flexDirection="column" paddingX={1} rowGap={1}>
686 <Box flexDirection="row" justifyContent="space-between">
687 <Box flexDirection="row" columnGap={1} flexShrink={1}>
688 <Text bold>Files changed</Text>
689 {isReady && (
690 <Box flexShrink={1}>
691 <Text dimColor wrap="truncate-end">
692 {rangeTitle(t)}
693 </Text>
694 </Box>
695 )}
696 </Box>
697 {position === 'band' && <Button key="refresh" plain label="↻" onPress={on.refresh} />}
698 </Box>
699 {isReady && position === 'pane' && strip(kit, t, bodyColumns - 2, on)}
700 {isReady ? filesView(kit, t, on) : notice(kit, t)}
701 </Box>
702 )
703}
704
705/** The strip is always on screen in a repository; the pane only counts while it is open. */
706const refreshIfShown = async ($: EngineInterface) => {
707 try {
708 if (position === 'band' || (await $.ui.panes()).some(pane => pane.id === PANE)) {
709 await refresh($)
710 }
711 } catch {
712 // The view keeps its last good state; the next trigger tries again.
713 }
714}
715
716/** One refresh per burst of tool calls, after the burst settles. */
717const scheduleRefresh = ($: EngineInterface) => {
718 if (pending !== undefined) {
719 return
720 }
721
722 pending = $.clock.after(REFRESH_DELAY_MS, () => {
723 pending = undefined
724 void refreshIfShown($)
725 })
726}
727
728const begin = async ($: EngineInterface) => {
729 try {
730 await refresh($)
731
732 if (position === 'band') {
733 // The strip replaces the side pane: close one an earlier load or setting left open.
734 await $.ui.close({ id: PANE })
735
736 return
737 }
738
739 const { status } = await read($, timeline)
740
741 if (status !== 'no-repo' && status !== 'error') {
742 wasPlaced = (await $.ui.open(PANE_ARGS)).isPlaced
743 }
744 } catch {
745 // Opening the pane by hand (/gitdiff) shows the error.
746 }
747}
748
749/**
750 * A pane opened at session start is unasked, and a desktop that has not attached yet
751 * seats it late or never. The first prompt of a repository session asks again: opened
752 * with the person's prompt behind it, it is seated at any width.
753 */
754const offerOnce = async ($: EngineInterface) => {
755 if (position !== 'pane' || hasOffered) {
756 return
757 }
758
759 try {
760 const { status } = await read($, timeline)
761
762 if (status === 'loading') {
763 return
764 }
765
766 hasOffered = true
767
768 if (!wasPlaced && status !== 'no-repo' && status !== 'error') {
769 wasPlaced = (await $.ui.open(PANE_ARGS)).isPlaced
770 }
771 } catch {
772 // The pane stays as it was; /gitdiff opens it by hand.
773 }
774}
775
776export const register: Register = (on, options) => {
777 position = options.position === 'pane' ? 'pane' : 'band'
778
779 on('session.start', async ($, e, next) => {
780 await $.command.register({ name: 'gitdiff', description: 'Open the Git Diff files view (what changed, file by file)' })
781 void begin($)
782
783 return next(e)
784 })
785
786 on('command.run', { command: 'gitdiff' }, async $ => {
787 await refresh($).catch(() => undefined)
788
789 const opened = await $.ui.open(PANE_ARGS)
790
791 wasPlaced = opened.isPlaced
792
793 return { text: opened.isPlaced ? 'Git Diff pane opened.' : `Git Diff pane is waiting: ${opened.reason}` }
794 })
795
796 on('prompt.submit', async ($, e, next) => {
797 await offerOnce($)
798
799 return next(e)
800 })
801
802 on('turn.complete', async ($, e, next) => {
803 const done = await next(e)
804
805 scheduleRefresh($)
806
807 return done
808 })
809
810 on('tool.call', async ($, e, next) => {
811 const ran = await next(e)
812
813 if (WATCHED.has(e.tool)) {
814 scheduleRefresh($)
815 }
816
817 return ran
818 })
819
820 // The strip above the prompt: drawn in a repository, left alone elsewhere.
821 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
822 if (position !== 'band' || e.props.hasSurvey) {
823 return next(e)
824 }
825
826 const t = await read($, timeline)
827
828 if (t.status !== 'ready' && t.status !== 'error') {
829 return next(e)
830 }
831
832 const kit = kitOf($.ui.resolve(e))
833 const { Text } = kit
834
835 return t.status === 'ready' ? (
836 strip(kit, t, e.props.bodyColumns, actionsFor($))
837 ) : (
838 <Text color={RED}>{`Git Diff: git failed: ${t.message}`}</Text>
839 )
840 })
841
842 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
843 const t = await read($, timeline)
844
845 return paneView(kitOf($.ui.resolve(e)), t, e.props.bodyColumns, actionsFor($))
846 })
847}
848hooks/card.ts 263 lines1import { slotCenter } from './layout'
2
3/**
4 * The cards the desktop draws as one Svg each: a deep navy card, the compared span
5 * aglow in orange-red. Units are tenths of a cell, so a dot drawn at a slot's centre
6 * sits over the commit button in that slot below the card.
7 */
8
9/** viewBox units per cell: a card is `columns * CELL` wide. */
10export const CELL = 10
11
12const H = 170
13const BY = 116
14const WAVE = 50
15const MAX_DOTS = 12
16
17const CARD = '#0f1729'
18const INK = '#eef1f7'
19const INK2 = '#a3adc2'
20const MUTED = '#8a94ab'
21const LINE = '#2a3550'
22const DOT = '#56627f'
23const IN_RANGE = '#c9d1e3'
24const ADDED = '#5fd38d'
25const DELETED = '#ff7b8a'
26const A1 = '#ffa25c'
27const A2 = '#ff4d2e'
28const FONT = 'Segoe UI, system-ui, -apple-system, sans-serif'
29
30export type CardNode = {
31 /** Under the dot: `Jul 4`, `Now`. */
32 label: string
33 /** Lines changed: the wave's height over the dot. */
34 churn: number
35 role: 'outside' | 'base' | 'between' | 'compare'
36 isWorking: boolean
37 /** What the dot is, for a reader that hovers or cannot see it. */
38 tip: string
39}
40
41export type CardStats = { files: string; added: string; deleted: string } | null
42
43export type HistoryCardInput = {
44 /** The band's width in cells: the card spans it. */
45 columns: number
46 /** The commits in view, oldest first, one per slot. */
47 nodes: CardNode[]
48 title: string
49 stats: CardStats
50 /** Commits beyond the view on each side. */
51 older: number
52 newer: number
53}
54
55export type BranchCardInput = {
56 columns: number
57 base: string
58 compare: string
59 ahead: number
60 behind: number
61 /** Titles of each side's commits, oldest first. */
62 aheadTips: string[]
63 behindTips: string[]
64 /** Under the fork: `merge base · Jul 13`; '' when the branches share no history. */
65 mergeBase: string
66 stats: CardStats
67}
68
69const r = (n: number) => Math.round(n * 10) / 10
70
71const esc = (text: string) =>
72 text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
73
74/** A smooth curve through the points (Catmull-Rom as cubic Béziers). */
75const smooth = (points: ReadonlyArray<readonly [number, number]>) => {
76 const [x0 = 0, y0 = 0] = points[0] ?? []
77 let d = `M${r(x0)},${r(y0)}`
78
79 for (let i = 0; i < points.length - 1; i += 1) {
80 const a = points[i - 1] ?? points[i]!
81 const b = points[i]!
82 const c = points[i + 1]!
83 const e = points[i + 2] ?? c
84
85 d += ` C${r(b[0] + (c[0] - a[0]) / 7)},${r(b[1] + (c[1] - a[1]) / 7)} ${r(c[0] - (e[0] - b[0]) / 7)},${r(c[1] - (e[1] - b[1]) / 7)} ${r(c[0])},${r(c[1])}`
86 }
87
88 return d
89}
90
91const open = (width: number, label: string, glowX1: number, glowX2: number) =>
92 [
93 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${H}" width="${width * 4}" height="${H * 4}" font-family="${FONT}" role="img" aria-label="${esc(label)}">`,
94 '<defs>',
95 '<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#151f37"/><stop offset="1" stop-color="#0b1220"/></linearGradient>',
96 `<linearGradient id="accent" gradientUnits="userSpaceOnUse" x1="${r(glowX1)}" y1="0" x2="${r(Math.max(glowX2, glowX1 + 1))}" y2="0"><stop offset="0" stop-color="${A1}"/><stop offset="1" stop-color="${A2}"/></linearGradient>`,
97 `<linearGradient id="fill" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${A2}" stop-opacity="0.5"/><stop offset="1" stop-color="${A2}" stop-opacity="0"/></linearGradient>`,
98 `<radialGradient id="halo"><stop offset="0" stop-color="${A2}" stop-opacity="0.22"/><stop offset="1" stop-color="${A2}" stop-opacity="0"/></radialGradient>`,
99 '<filter id="glow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="4" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>',
100 '</defs>',
101 `<rect width="${width}" height="${H}" rx="22" fill="url(#bg)"/>`,
102 ].join('')
103
104/** The header's right side: the totals, or that they are on their way. */
105const statsText = (x: number, stats: CardStats) =>
106 stats === null
107 ? `<text x="${r(x)}" y="30" font-size="15" fill="${MUTED}" text-anchor="end">reading the diff…</text>`
108 : `<text x="${r(x)}" y="30" font-size="15" text-anchor="end"><tspan fill="${INK}">${esc(stats.files)}</tspan><tspan dx="14" fill="${ADDED}">${esc(stats.added)}</tspan><tspan dx="10" fill="${DELETED}">${esc(stats.deleted)}</tspan></text>`
109
110const glowDot = (x: number, y: number, extra = '') =>
111 `<circle cx="${r(x)}" cy="${y}" r="17" fill="${A2}" opacity="0.35" filter="url(#glow)"/><circle${extra} cx="${r(x)}" cy="${y}" r="8" fill="${A2}" stroke="${CARD}" stroke-width="3"/>`
112
113const nodeMark = (node: CardNode, i: number, x: number) => {
114 const at = ` data-node="${i}" cx="${r(x)}" cy="${BY}"`
115 const role = ` data-role="${node.role}"`
116 const dashed = node.isWorking ? ' stroke-dasharray="3 3"' : ''
117
118 switch (node.role) {
119 case 'compare':
120 return `<circle cx="${r(x)}" cy="${BY}" r="17" fill="${A2}" opacity="0.35" filter="url(#glow)"/><circle${at} r="8"${role} fill="${A2}" stroke="${CARD}" stroke-width="3"/>`
121 case 'base':
122 return `<circle${at} r="7"${role} fill="${CARD}" stroke="${A1}" stroke-width="3"${dashed}/>`
123 case 'between':
124 return `<circle${at} r="4.5"${role} fill="${IN_RANGE}"/>`
125 default:
126 return node.isWorking
127 ? `<circle${at} r="5"${role} fill="${CARD}" stroke="${IN_RANGE}" stroke-width="2"${dashed}/>`
128 : `<circle${at} r="4.5"${role} fill="${DOT}"/>`
129 }
130}
131
132/** History: the commits in view on a line, the wave of their size above it, the pick aglow. */
133export const historyCard = ({ columns, nodes, title, stats, older, newer }: HistoryCardInput): string => {
134 const width = columns * CELL
135 const xs = nodes.map((_, i) => slotCenter(i) * CELL)
136 const tallest = Math.log10(1 + Math.max(1, ...nodes.map(node => node.churn)))
137 const ys = nodes.map(node => BY - 6 - (WAVE * Math.log10(1 + node.churn)) / tallest)
138 const left = 16
139 const right = width - 16
140 const wave = smooth([[left, BY], ...xs.map((x, i) => [x, ys[i]!] as const), [right, BY]])
141 const area = `${wave} L${right},${BY} L${left},${BY} Z`
142 const picked = nodes.map((node, i) => (node.role === 'outside' ? -1 : i)).filter(i => i >= 0)
143 const firstPicked = picked[0]
144 const lastPicked = picked[picked.length - 1]
145 const hasSpan = firstPicked !== undefined && lastPicked !== undefined
146 // A pick that runs out of view glows to the card's edge on that side.
147 const spanFrom = !hasSpan ? 0 : nodes[firstPicked]!.role === 'between' ? left : xs[firstPicked]!
148 const spanTo = !hasSpan ? 0 : nodes[lastPicked]!.role === 'between' ? right : xs[lastPicked]!
149 const parts = [
150 open(width, title, spanFrom, spanTo),
151 hasSpan ? `<ellipse cx="${r((spanFrom + spanTo) / 2)}" cy="${BY - 30}" rx="${r(Math.max(120, (spanTo - spanFrom) / 2 + 80))}" ry="90" fill="url(#halo)"/>` : '',
152 `<text x="24" y="30" font-size="15" fill="${INK2}">${esc(title)}</text>`,
153 statsText(width - 24, stats),
154 `<path d="${area}" fill="#18233c"/>`,
155 `<path d="${wave}" fill="none" stroke="#2f3d5c" stroke-width="2"/>`,
156 ]
157
158 if (hasSpan && spanTo > spanFrom) {
159 parts.push(
160 `<clipPath id="span"><rect x="${r(spanFrom)}" y="0" width="${r(spanTo - spanFrom)}" height="${H}"/></clipPath>`,
161 `<g clip-path="url(#span)"><path d="${area}" fill="url(#fill)"/><path d="${wave}" fill="none" stroke="url(#accent)" stroke-width="3" filter="url(#glow)"/></g>`,
162 )
163 }
164
165 parts.push(`<line x1="${left}" y1="${BY}" x2="${right}" y2="${BY}" stroke="${LINE}" stroke-width="3" stroke-linecap="round"/>`)
166
167 if (hasSpan && spanTo > spanFrom) {
168 parts.push(
169 `<line x1="${r(spanFrom)}" y1="${BY}" x2="${r(spanTo)}" y2="${BY}" stroke="url(#accent)" stroke-width="5" stroke-linecap="round" filter="url(#glow)"/>`,
170 )
171 }
172
173 nodes.forEach((node, i) => {
174 const x = xs[i]!
175 const isPicked = node.role === 'base' || node.role === 'compare'
176
177 parts.push(
178 `<g><title>${esc(node.tip)}</title>${nodeMark(node, i, x)}</g>`,
179 `<text x="${r(x)}" y="146" font-size="13" fill="${isPicked ? INK : MUTED}" text-anchor="middle">${esc(node.label)}</text>`,
180 )
181 })
182
183 if (older > 0) {
184 parts.push(`<text x="20" y="164" font-size="12" fill="${MUTED}">‹ ${older} older</text>`)
185 }
186
187 if (newer > 0) {
188 parts.push(`<text x="${width - 20}" y="164" font-size="12" fill="${MUTED}" text-anchor="end">${newer} newer ›</text>`)
189 }
190
191 parts.push('</svg>')
192
193 return parts.join('')
194}
195
196/** Spreads `count` dots from `x1` to `x2`, the last one at `x2`. */
197const spread = (count: number, x1: number, x2: number) =>
198 Array.from({ length: count }, (_, i) => (count === 1 ? x2 : x1 + ((x2 - x1) * i) / (count - 1)))
199
200/** Branches: the base line, the compare branch forking off it, each side's commits as dots. */
201export const branchCard = (input: BranchCardInput): string => {
202 const { columns, base, compare, ahead, behind, aheadTips, behindTips, mergeBase, stats } = input
203 const width = columns * CELL
204 const fork = Math.round(width * 0.22)
205 const top = 68
206 const tipX = width - 40
207 const aheadCount = Math.min(ahead, MAX_DOTS)
208 const behindCount = Math.min(behind, MAX_DOTS)
209 const aheadXs = spread(aheadCount, fork + 90, tipX)
210 const behindXs = spread(behindCount, fork + 90, tipX)
211 const label = `${compare} is ${ahead} ahead of and ${behind} behind ${base}`
212 const parts = [
213 open(width, label, fork, tipX),
214 `<ellipse cx="${r((fork + tipX) / 2)}" cy="${top}" rx="${r((tipX - fork) / 2 + 60)}" ry="70" fill="url(#halo)"/>`,
215 `<text x="24" y="30" font-size="15"><tspan fill="${INK}">${ahead} ahead</tspan><tspan dx="8" fill="${MUTED}">·</tspan><tspan dx="8" fill="${INK}">${behind} behind</tspan></text>`,
216 statsText(width - 24, stats),
217 `<line x1="16" y1="${BY}" x2="${width - 16}" y2="${BY}" stroke="${LINE}" stroke-width="3" stroke-linecap="round"/>`,
218 ...[0.25, 0.5, 0.75].map(at => `<circle cx="${r(fork * at)}" cy="${BY}" r="4.5" fill="${DOT}"/>`),
219 ]
220
221 behindXs.forEach((x, i) => {
222 const tip = behindTips[behindTips.length - behindCount + i] ?? ''
223 const isTip = i === behindCount - 1
224
225 parts.push(
226 `<g><title>${esc(tip)}</title><circle data-side="behind" cx="${r(x)}" cy="${BY}" r="${isTip ? 6 : 4.5}" fill="${isTip ? IN_RANGE : DOT}"/></g>`,
227 )
228 })
229
230 if (ahead > 0) {
231 parts.push(
232 `<path d="M${fork},${BY} C${fork + 40},${BY} ${fork + 36},${top} ${fork + 76},${top} L${tipX},${top}" fill="none" stroke="url(#accent)" stroke-width="3" stroke-linecap="round" filter="url(#glow)"/>`,
233 )
234 }
235
236 aheadXs.forEach((x, i) => {
237 const tip = aheadTips[aheadTips.length - aheadCount + i] ?? ''
238 const isTip = i === aheadCount - 1
239
240 parts.push(
241 `<g><title>${esc(tip)}</title>${isTip ? glowDot(x, top, ' data-side="ahead"') : `<circle data-side="ahead" cx="${r(x)}" cy="${top}" r="4" fill="#ffc6a6"/>`}</g>`,
242 )
243 })
244
245 if (ahead > aheadCount) {
246 parts.push(`<text x="${fork + 90}" y="${top - 16}" font-size="12" fill="${MUTED}">+${ahead - aheadCount} more</text>`)
247 }
248
249 if (behind > behindCount) {
250 parts.push(`<text x="${fork + 90}" y="${BY + 22}" font-size="12" fill="${MUTED}">+${behind - behindCount} more</text>`)
251 }
252
253 parts.push(
254 `<circle cx="${fork}" cy="${BY}" r="7" fill="${CARD}" stroke="${IN_RANGE}" stroke-width="2.5"/>`,
255 `<text x="${tipX}" y="${top - 18}" font-size="14" fill="${INK2}" text-anchor="end">${esc(compare)}</text>`,
256 `<text x="${tipX}" y="${BY + 26}" font-size="14" fill="${INK2}" text-anchor="end">${esc(base)}</text>`,
257 mergeBase === '' ? '' : `<text x="${fork}" y="${BY + 26}" font-size="12" fill="${MUTED}" text-anchor="middle">${esc(mergeBase)}</text>`,
258 '</svg>',
259 )
260
261 return parts.join('')
262}
263hooks/git.ts 329 lines1import type {
2 GitDiffBranch,
3 GitDiffBranchCompare,
4 GitDiffCommit,
5 GitDiffFile,
6 GitDiffPatch,
7 GitDiffStat,
8} from '../types'
9
10export type Run = (
11 argv: readonly string[],
12) => Promise<{ exitCode: number; stdout: string; stderr: string }>
13
14export type Loaded = {
15 status: 'ready' | 'no-repo' | 'no-commits' | 'error'
16 message: string
17 branch: string
18 commits: GitDiffCommit[]
19 hasWorking: boolean
20 untracked: number
21 baseOfOldest: string
22 branches: GitDiffBranch[]
23 /** The branch others are compared with by default: origin's default branch, else a main one. */
24 defaultBase: string
25}
26
27/** The revisions of one `git diff`: two, one (against the working tree), or `base...compare`. */
28export type Spec = readonly string[]
29
30export const LOG_LIMIT = 60
31export const BRANCH_COMMITS = 30
32/** Characters of hunks one file's diff may hold: a Code element takes at most 10000. */
33export const PATCH_LIMIT = 9000
34
35/** The tree with nothing in it, which a root commit is compared with. */
36export const EMPTY_TREE = {
37 sha1: '4b825dc642cb6eb9a060e54bf8d69288fbee4904',
38 sha256: '6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321',
39} as const
40
41/** Characters of a commit message's body kept: enough for a pull request's title and a line or two. */
42const BODY_LIMIT = 300
43
44const RS = '\u001e'
45const US = '\u001f'
46const GS = '\u001d'
47const LOG_FORMAT = '--format=%x1e%H%x1f%h%x1f%ct%x1f%s%x1f%b%x1d'
48const REF_FORMAT = '--format=%(refname)%1f%(refname:short)%1f%(committerdate:unix)%1f%(symref)'
49const NUMSTAT = ['diff', '--numstat', '-z', '--no-renames']
50const PATCH = ['diff', '--no-color', '--no-ext-diff', '--no-renames', '-U3']
51const MAIN_BRANCHES = ['origin/main', 'origin/master', 'main', 'master']
52/** Control characters a Code element refuses: all but tab and newline. */
53const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/g
54
55const firstLine = (text: string) => text.trim().split(/\r?\n/)[0] ?? ''
56
57const countOf = (text: string, pattern: RegExp) => Number(pattern.exec(text)?.[1] ?? 0)
58
59/** `git log` with LOG_FORMAT and `--shortstat`: newest first in, oldest first out. */
60export const parseLog = (stdout: string): GitDiffCommit[] =>
61 stdout
62 .split(RS)
63 .filter(chunk => chunk.trim() !== '')
64 .map(chunk => {
65 // The message ends at GS; the shortstat, when there is one, follows it.
66 const end = chunk.lastIndexOf(GS)
67 const fields = end < 0 ? chunk : chunk.slice(0, end)
68 const stat = end < 0 ? '' : chunk.slice(end + 1)
69 const [sha = '', short = '', time = '', subject = '', ...body] = fields.split(US)
70
71 return {
72 sha,
73 short,
74 time: Number(time),
75 subject: subject.trim(),
76 body: body.join(US).replace(/\r/g, '').trim().slice(0, BODY_LIMIT),
77 added: countOf(stat, /(\d+) insertions?\(\+\)/),
78 deleted: countOf(stat, /(\d+) deletions?\(-\)/),
79 }
80 })
81 .filter(commit => commit.sha !== '' && Number.isFinite(commit.time))
82 .reverse()
83
84/** `git diff --numstat -z --no-renames`: `added TAB deleted TAB path NUL` per file. */
85export const parseNumstat = (stdout: string): GitDiffStat => {
86 const files: GitDiffFile[] = stdout
87 .split('\0')
88 .filter(record => record !== '')
89 .map(record => {
90 const [added = '', deleted = '', ...path] = record.split('\t')
91
92 return {
93 path: path.join('\t'),
94 added: added === '-' ? null : Number(added),
95 deleted: deleted === '-' ? null : Number(deleted),
96 }
97 })
98
99 return {
100 files,
101 added: files.reduce((sum, file) => sum + (file.added ?? 0), 0),
102 deleted: files.reduce((sum, file) => sum + (file.deleted ?? 0), 0),
103 }
104}
105
106/** `git for-each-ref` with REF_FORMAT; symbolic refs (`origin/HEAD`) left out. */
107export const parseBranches = (stdout: string): GitDiffBranch[] =>
108 stdout
109 .split(/\r?\n/)
110 .map(line => line.split(US))
111 .filter(([refname = '', , , symref = '']) => refname !== '' && symref === '')
112 .map(([refname = '', name = '', time = '']) => ({
113 name,
114 time: Number(time) || 0,
115 isRemote: refname.startsWith('refs/remotes/'),
116 }))
117
118/** `git rev-list --left-right --count base...compare`: the base's own commits, then the compare's. */
119export const parseLeftRight = (stdout: string) => {
120 const [behind = '0', ahead = '0'] = stdout.trim().split(/\s+/)
121
122 return { behind: Number(behind) || 0, ahead: Number(ahead) || 0 }
123}
124
125/** A hunk whose lines were cut: its header counted again from what is left. */
126const recount = ([header = '', ...body]: string[]): string[] => {
127 const match = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/.exec(header)
128
129 if (match === null) {
130 return [header, ...body]
131 }
132
133 const old = body.filter(line => line.startsWith(' ') || line.startsWith('-')).length
134 const now = body.filter(line => line.startsWith(' ') || line.startsWith('+')).length
135
136 return [`@@ -${match[1]},${old} +${match[2]},${now} @@${match[3]}`, ...body]
137}
138
139/**
140 * One file's `git diff` as a Code element takes it: the hunks alone, without control
141 * characters, whole hunks up to `limit` characters (the first one cut to fit if it must).
142 */
143export const toHunks = (diff: string, limit = PATCH_LIMIT): Omit<GitDiffPatch, 'path'> => {
144 const lines = diff.replace(/\r/g, '').replace(CONTROL, '�').split('\n')
145 const first = lines.findIndex(line => line.startsWith('@@'))
146
147 if (first < 0) {
148 return { hunks: '', isCut: false, note: /^Binary files /m.test(diff) ? 'binary' : 'empty' }
149 }
150
151 const body = lines.slice(first)
152
153 while (body.length > 0 && body[body.length - 1] === '') {
154 body.pop()
155 }
156
157 const hunks: string[][] = []
158
159 for (const line of body) {
160 if (line.startsWith('@@') || hunks.length === 0) {
161 hunks.push([line])
162 } else {
163 hunks[hunks.length - 1]!.push(line)
164 }
165 }
166
167 const kept: string[] = []
168 let size = 0
169
170 for (const hunk of hunks) {
171 const text = hunk.join('\n')
172 const extra = (kept.length === 0 ? 0 : 1) + text.length
173
174 if (size + extra <= limit) {
175 kept.push(text)
176 size += extra
177 continue
178 }
179
180 if (kept.length === 0) {
181 const head = [hunk[0] ?? '']
182 let headSize = head[0]!.length
183
184 for (const line of hunk.slice(1)) {
185 if (headSize + 1 + line.length > limit) {
186 break
187 }
188
189 head.push(line)
190 headSize += 1 + line.length
191 }
192
193 kept.push(recount(head).join('\n'))
194 }
195
196 return { hunks: kept.join('\n'), isCut: true, note: '' }
197 }
198
199 return { hunks: kept.join('\n'), isCut: false, note: '' }
200}
201
202const empty = (status: Loaded['status'], message: string, branch = ''): Loaded => ({
203 status,
204 message,
205 branch,
206 commits: [],
207 hasWorking: false,
208 untracked: 0,
209 baseOfOldest: '',
210 branches: [],
211 defaultBase: '',
212})
213
214const pickDefaultBase = (originHead: string, branches: GitDiffBranch[]) => {
215 const names = new Set(branches.map(branch => branch.name))
216
217 if (names.has(originHead)) {
218 return originHead
219 }
220
221 return MAIN_BRANCHES.find(name => names.has(name)) ?? branches[0]?.name ?? ''
222}
223
224/** What the oldest commit is compared with: its first parent, or the empty tree for a root. */
225const baseOf = async (run: Run, oldest: GitDiffCommit) => {
226 const parent = await run(['git', 'rev-parse', '--verify', '--quiet', `${oldest.sha}^`])
227
228 if (parent.exitCode === 0 && parent.stdout.trim() !== '') {
229 return parent.stdout.trim()
230 }
231
232 const format = await run(['git', 'rev-parse', '--show-object-format'])
233
234 return format.stdout.trim() === 'sha256' ? EMPTY_TREE.sha256 : EMPTY_TREE.sha1
235}
236
237export const loadTimeline = async (run: Run): Promise<Loaded> => {
238 const inside = await run(['git', 'rev-parse', '--is-inside-work-tree'])
239
240 if (inside.exitCode !== 0 || inside.stdout.trim() !== 'true') {
241 return empty('no-repo', firstLine(inside.stderr) || 'Not a git repository')
242 }
243
244 const [branchRun, head, dirty, others, refs, originHead] = await Promise.all([
245 run(['git', 'branch', '--show-current']),
246 run(['git', 'rev-parse', '--verify', '--quiet', 'HEAD']),
247 run(['git', ...NUMSTAT, 'HEAD', '--']),
248 run(['git', 'ls-files', '--others', '--exclude-standard', '-z']),
249 run(['git', 'for-each-ref', '--sort=-committerdate', REF_FORMAT, 'refs/heads', 'refs/remotes']),
250 run(['git', 'symbolic-ref', '-q', '--short', 'refs/remotes/origin/HEAD']),
251 ])
252 const branch = branchRun.stdout.trim() || 'detached HEAD'
253
254 if (head.exitCode !== 0) {
255 return empty('no-commits', `No commits yet on ${branch}`, branch)
256 }
257
258 // First parent only: a merged pull request is one commit, so each step is one pull request's diff.
259 const log = await run(['git', 'log', '--first-parent', '-n', String(LOG_LIMIT), LOG_FORMAT, '--shortstat'])
260
261 if (log.exitCode !== 0) {
262 return empty('error', firstLine(log.stderr) || 'git log failed', branch)
263 }
264
265 const commits = parseLog(log.stdout)
266 const branches = refs.exitCode === 0 ? parseBranches(refs.stdout) : []
267 const oldest = commits[0]
268
269 return {
270 status: 'ready',
271 message: '',
272 branch,
273 commits,
274 hasWorking: dirty.exitCode === 0 && dirty.stdout !== '',
275 untracked: others.exitCode === 0 ? others.stdout.split('\0').filter(path => path !== '').length : 0,
276 baseOfOldest: oldest === undefined ? '' : await baseOf(run, oldest),
277 branches,
278 defaultBase: pickDefaultBase(originHead.exitCode === 0 ? originHead.stdout.trim() : '', branches),
279 }
280}
281
282/** The files a diff of `spec` changes. */
283export const loadStat = async (run: Run, spec: Spec): Promise<GitDiffStat> => {
284 const result = await run(['git', ...NUMSTAT, ...spec, '--'])
285
286 if (result.exitCode !== 0) {
287 throw new Error(firstLine(result.stderr) || 'git diff failed')
288 }
289
290 return parseNumstat(result.stdout)
291}
292
293/** One file's diff of `spec`, as hunks; a failure is kept as the note. */
294export const loadPatch = async (run: Run, spec: Spec, path: string): Promise<GitDiffPatch> => {
295 const result = await run(['git', ...PATCH, ...spec, '--', path])
296
297 if (result.exitCode !== 0) {
298 return { path, hunks: '', isCut: false, note: firstLine(result.stderr) || 'git diff failed' }
299 }
300
301 return { path, ...toHunks(result.stdout) }
302}
303
304/** How far apart two branches are: each side's own commits, and where they split. */
305export const loadBranchCompare = async (run: Run, base: string, compare: string): Promise<GitDiffBranchCompare> => {
306 const side = (range: string) =>
307 run(['git', 'log', '--first-parent', '-n', String(BRANCH_COMMITS), LOG_FORMAT, '--shortstat', range])
308 const [counts, mergeBase, aheadLog, behindLog] = await Promise.all([
309 run(['git', 'rev-list', '--left-right', '--count', `${base}...${compare}`]),
310 run(['git', 'merge-base', base, compare]),
311 side(`${base}..${compare}`),
312 side(`${compare}..${base}`),
313 ])
314
315 if (counts.exitCode !== 0) {
316 throw new Error(firstLine(counts.stderr) || 'git rev-list failed')
317 }
318
319 const baseSha = mergeBase.exitCode === 0 ? mergeBase.stdout.trim() : ''
320 const baseLog = baseSha === '' ? null : await run(['git', 'log', '-1', LOG_FORMAT, baseSha])
321
322 return {
323 ...parseLeftRight(counts.stdout),
324 mergeBase: baseLog === null || baseLog.exitCode !== 0 ? null : (parseLog(baseLog.stdout)[0] ?? null),
325 aheadCommits: aheadLog.exitCode === 0 ? parseLog(aheadLog.stdout) : [],
326 behindCommits: behindLog.exitCode === 0 ? parseLog(behindLog.stdout) : [],
327 }
328}
329hooks/layout.ts 78 lines1/** Cells each paging arrow takes, either end of the commit buttons. */
2export const ARROW_W = 3
3
4/** Cells one commit takes: room for a `#1234` or short-sha button. */
5export const SLOT_W = 9
6
7const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
8
9const two = (n: number) => String(n).padStart(2, '0')
10
11/** `Jan 2, 2026`, in local time. */
12export const formatDate = (seconds: number): string => {
13 const date = new Date(seconds * 1000)
14
15 return `${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`
16}
17
18/** `Jan 2`, in local time. */
19export const shortDate = (seconds: number): string => {
20 const date = new Date(seconds * 1000)
21
22 return `${MONTHS[date.getMonth()]} ${date.getDate()}`
23}
24
25/** `02:40 PM`, in local time. */
26export const formatTime = (seconds: number): string => {
27 const date = new Date(seconds * 1000)
28 const hours = date.getHours()
29
30 return `${two(hours % 12 || 12)}:${two(date.getMinutes())} ${hours < 12 ? 'AM' : 'PM'}`
31}
32
33/** `9:47 PM`, in local time. */
34const shortTime = (seconds: number): string => {
35 const date = new Date(seconds * 1000)
36 const hours = date.getHours()
37
38 return `${hours % 12 || 12}:${two(date.getMinutes())} ${hours < 12 ? 'AM' : 'PM'}`
39}
40
41const dayOf = (seconds: number) => new Date(seconds * 1000).toDateString()
42
43/** The label under each dot: its day where the day changes, else its time; null is the working tree. */
44export const dayLabels = (times: ReadonlyArray<number | null>): string[] =>
45 times.map((time, i) => {
46 if (time === null) {
47 return 'Now'
48 }
49
50 const before = i === 0 ? null : times[i - 1]
51
52 return before === null || before === undefined || dayOf(before) !== dayOf(time) ? shortDate(time) : shortTime(time)
53 })
54
55/** How many commit slots fit between the arrows; at least two, at most all of them. */
56export const visibleCount = (columns: number, total: number): number => {
57 const room = Math.floor((columns - 2 * ARROW_W) / SLOT_W)
58
59 return Math.max(2, Math.min(total, room))
60}
61
62/** The centre of slot `i`, in cells from the left edge. */
63export const slotCenter = (i: number): number => ARROW_W + i * SLOT_W + SLOT_W / 2
64
65const clampStart = (start: number, count: number, total: number) =>
66 Math.min(Math.max(0, total - count), Math.max(0, start))
67
68/** The leftmost visible node: `-1` means the newest window. */
69export const resolveStart = (start: number, count: number, total: number): number =>
70 start < 0 ? Math.max(0, total - count) : clampStart(start, count, total)
71
72/** One page left (`delta` -1) or right (1), overlapping by a node; `-1` once at the newest. */
73export const stepStart = (start: number, delta: number, count: number, total: number): number => {
74 const next = clampStart(resolveStart(start, count, total) + delta * Math.max(1, count - 1), count, total)
75
76 return next === Math.max(0, total - count) ? -1 : next
77}
78hooks/model.ts 221 lines1import type { GitDiffCommit, GitDiffStat, GitDiffTimeline } from '../types'
2import type { Loaded } from './git'
3
4export type Range = { from: number; to: number }
5
6/** The id of the uncommitted-changes node, after the last commit. */
7export const WORKING = 'working'
8
9export const INITIAL: GitDiffTimeline = {
10 status: 'loading',
11 message: '',
12 cwd: '',
13 branch: '',
14 mode: 'history',
15 commits: [],
16 hasWorking: false,
17 untracked: 0,
18 baseOfOldest: '',
19 from: 0,
20 to: 0,
21 start: -1,
22 isPinned: false,
23 anchor: '',
24 branches: [],
25 base: '',
26 compare: '',
27 branchCompare: null,
28 stat: null,
29 statKey: '',
30 statError: '',
31 open: [],
32 patches: [],
33}
34
35type Nodes = Pick<GitDiffTimeline, 'commits' | 'hasWorking'>
36type Picked = Nodes & Range & Pick<GitDiffTimeline, 'baseOfOldest' | 'mode' | 'base' | 'compare'>
37
38/** One id per timeline node, oldest first: commit shas, then `WORKING` when dirty. */
39export const nodeIds = (t: Nodes): string[] => [
40 ...t.commits.map(commit => commit.sha),
41 ...(t.hasWorking ? [WORKING] : []),
42]
43
44/** The revision node `index` stands for; -1 is what the oldest commit is compared with. */
45export const refOf = (t: Nodes & Pick<GitDiffTimeline, 'baseOfOldest'>, index: number): string =>
46 index < 0 ? t.baseOfOldest : (nodeIds(t)[index] ?? '')
47
48/** The revisions of the `git diff` the pick shows: two, one against the working tree, or base...compare. */
49export const diffSpec = (t: Picked): string[] => {
50 if (t.mode === 'branches') {
51 return [`${t.base}...${t.compare}`]
52 }
53
54 const to = refOf(t, t.to)
55
56 return to === WORKING ? [refOf(t, t.from)] : [refOf(t, t.from), to]
57}
58
59/** What a stat or a patch was read for. */
60export const selectionKey = (t: Picked): string =>
61 t.mode === 'branches' ? `${t.base}...${t.compare}` : `${refOf(t, t.from)}..${refOf(t, t.to)}`
62
63/** A commit on its own: against the node before it. */
64export const commitRange = (index: number): Range => ({ from: index - 1, to: index })
65
66/**
67 * A click on node `index`, as a date-range picker takes it: the first shows that commit
68 * on its own and waits; a second on another node compares the two; the next starts over.
69 */
70export const clickCommit = (t: Nodes & Pick<GitDiffTimeline, 'anchor'>, index: number): Range & { anchor: string } => {
71 const ids = nodeIds(t)
72 const waiting = t.anchor === '' ? -1 : ids.indexOf(t.anchor)
73
74 if (waiting < 0) {
75 return { ...commitRange(index), anchor: ids[index] ?? '' }
76 }
77
78 return waiting === index
79 ? { ...commitRange(index), anchor: '' }
80 : { from: Math.min(waiting, index), to: Math.max(waiting, index), anchor: '' }
81}
82
83/** The commits a history comparison takes in, newest first; the working tree is none of them. */
84export const rangeCommits = (t: Nodes & Range): GitDiffCommit[] =>
85 t.commits.slice(Math.max(0, t.from + 1), Math.min(t.to + 1, t.commits.length)).reverse()
86
87/** The newest step: the newest node on its own. */
88export const defaultRange = (total: number): Range => commitRange(total - 1)
89
90const grouped = (n: number) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
91
92/** The three phrases of a summary: `4 files changed`, `+172`, `−35`; null while unread. */
93export const describeStat = (stat: GitDiffStat | null) =>
94 stat === null
95 ? null
96 : {
97 files: `${stat.files.length} file${stat.files.length === 1 ? '' : 's'} changed`,
98 added: `+${grouped(stat.added)}`,
99 deleted: `−${grouped(stat.deleted)}`,
100 }
101
102/** A merge's subject by its pull request or branch; any other subject as it is. */
103const subjectName = (subject: string): string => {
104 const pull = /^Merge pull request #(\d+) from [^/\s]+\/(\S+)/.exec(subject)
105
106 if (pull !== null) {
107 return `PR #${pull[1]} ${pull[2]}`
108 }
109
110 const branch = /^Merge branch '([^']+)'/.exec(subject)
111
112 return branch === null ? subject : `Merge ${branch[1]}`
113}
114
115const firstLine = (text: string) => text.split('\n').find(line => line.trim() !== '')?.trim() ?? ''
116
117/** A GitLab merge's request number, from the `See merge request group/project!123` line it carries. */
118const mergeRequestOf = (commit: Pick<GitDiffCommit, 'subject' | 'body'>) =>
119 /^Merge branch '/.test(commit.subject) ? (/See merge request \S*!(\d+)/.exec(commit.body)?.[1] ?? null) : null
120
121/** A commit button's label: its pull or merge request number, else its short sha. */
122export const buttonLabel = (commit: GitDiffCommit): string => {
123 const pull = /^Merge pull request #(\d+)/.exec(commit.subject)
124
125 if (pull !== null) {
126 return `#${pull[1]}`
127 }
128
129 const request = mergeRequestOf(commit)
130
131 return request === null ? commit.short : `!${request}`
132}
133
134/**
135 * A commit as the timeline names it: a merge by its pull or merge request's title, else its
136 * subject. Whole: the row drawing it cuts it to the room it has.
137 */
138export const commitTitle = (commit: Pick<GitDiffCommit, 'subject' | 'body'>): string => {
139 const title = firstLine(commit.body)
140 const pull = /^Merge pull request #(\d+)/.exec(commit.subject)
141
142 if (pull !== null) {
143 return title === '' ? subjectName(commit.subject) : `#${pull[1]} ${title}`
144 }
145
146 const request = mergeRequestOf(commit)
147
148 return request !== null && title !== '' && !title.startsWith('See merge request')
149 ? `!${request} ${title}`
150 : subjectName(commit.subject)
151}
152
153/** The branches compared: kept while they exist, else origin's default against the current one. */
154const branchPick = (prev: GitDiffTimeline, loaded: Loaded, isSameRepo: boolean) => {
155 const names = new Set(loaded.branches.map(branch => branch.name))
156 const kept = (name: string) => (isSameRepo && names.has(name) ? name : '')
157 const base = kept(prev.base) || loaded.defaultBase || loaded.branches[0]?.name || ''
158 const current = names.has(loaded.branch) ? loaded.branch : ''
159 const compare =
160 kept(prev.compare) || current || loaded.branches.find(branch => branch.name !== base)?.name || base
161
162 return { base, compare }
163}
164
165/** The next state once git answered; keeps what the person picked while it still exists. */
166export const merge = (prev: GitDiffTimeline, loaded: Loaded, cwd: string): GitDiffTimeline => {
167 const isSameRepo = prev.cwd === cwd
168 const bare: GitDiffTimeline = {
169 ...INITIAL,
170 cwd,
171 status: loaded.status,
172 message: loaded.message,
173 branch: loaded.branch,
174 mode: isSameRepo ? prev.mode : 'history',
175 }
176
177 if (loaded.status !== 'ready') {
178 return bare
179 }
180
181 const next: GitDiffTimeline = {
182 ...bare,
183 commits: loaded.commits,
184 hasWorking: loaded.hasWorking,
185 untracked: loaded.untracked,
186 baseOfOldest: loaded.baseOfOldest,
187 branches: loaded.branches,
188 ...branchPick(prev, loaded, isSameRepo),
189 }
190 const ids = nodeIds(next)
191 const locate = (ref: string): number | null => {
192 if (ref === '') {
193 return null
194 }
195
196 if (ref === next.baseOfOldest) {
197 return -1
198 }
199
200 const index = ids.indexOf(ref)
201
202 return index < 0 ? null : index
203 }
204 const from = isSameRepo && prev.isPinned ? locate(refOf(prev, prev.from)) : null
205 const to = isSameRepo && prev.isPinned ? locate(refOf(prev, prev.to)) : null
206 const kept = from !== null && to !== null && to > from ? { from, to } : null
207 const merged: GitDiffTimeline = {
208 ...next,
209 ...(kept ?? defaultRange(ids.length)),
210 isPinned: kept !== null,
211 anchor: isSameRepo && ids.includes(prev.anchor) ? prev.anchor : '',
212 start: isSameRepo ? prev.start : -1,
213 branchCompare: isSameRepo ? prev.branchCompare : null,
214 }
215
216 // What is on screen stays until the fresh answer lands, unless the pick moved.
217 return selectionKey(merged) === prev.statKey
218 ? { ...merged, stat: prev.stat, statKey: prev.statKey, statError: prev.statError, open: prev.open, patches: prev.patches }
219 : merged
220}
221types/index.d.ts 98 lines1export type GitDiffCommit = {
2 sha: string
3 short: string
4 /** Commit time, seconds since the epoch. */
5 time: number
6 subject: string
7 /** The message after the subject, trimmed and cut short: a merge's pull request title lives here. */
8 body: string
9 /** Lines changed against the first parent: the height of the card's wave. */
10 added: number
11 deleted: number
12}
13
14export type GitDiffFile = {
15 path: string
16 /** Null for a binary file. */
17 added: number | null
18 deleted: number | null
19}
20
21export type GitDiffStat = {
22 files: GitDiffFile[]
23 added: number
24 deleted: number
25}
26
27export type GitDiffBranch = {
28 /** As git spells it short: `main`, `origin/main`. */
29 name: string
30 /** Last commit time, seconds since the epoch. */
31 time: number
32 isRemote: boolean
33}
34
35/** One file's diff as the files view draws it. */
36export type GitDiffPatch = {
37 path: string
38 /** Unified-diff hunks from the first `@@`, cut to fit a Code element; '' when there are none. */
39 hunks: string
40 /** True when hunks were left out or cut to fit. */
41 isCut: boolean
42 /** Why nothing is drawn: `binary`, `empty`, or what git said. */
43 note: string
44}
45
46export type GitDiffBranchCompare = {
47 /** Commits on the compare branch that the base lacks, and the other way round. */
48 ahead: number
49 behind: number
50 mergeBase: GitDiffCommit | null
51 /** Oldest first, the newest few of each side. */
52 aheadCommits: GitDiffCommit[]
53 behindCommits: GitDiffCommit[]
54}
55
56export type GitDiffTimeline = {
57 status: 'loading' | 'ready' | 'too-short' | 'no-repo' | 'no-commits' | 'error'
58 message: string
59 cwd: string
60 branch: string
61 /** `history` compares two points of this branch; `branches` compares two branches. */
62 mode: 'history' | 'branches'
63 /** Oldest first. */
64 commits: GitDiffCommit[]
65 /** True while tracked files differ from HEAD: one more node after the commits. */
66 hasWorking: boolean
67 untracked: number
68 /** What the oldest commit is compared with: its parent, or the empty tree for a root. */
69 baseOfOldest: string
70 /** The compared range, as node indexes: `from` is older than `to`; -1 is `baseOfOldest`. */
71 from: number
72 to: number
73 /** Index of the leftmost commit button; -1 follows the newest. */
74 start: number
75 /** True once the person picked a range, so a refresh keeps it. */
76 isPinned: boolean
77 /** The commit clicked first, waiting for a second click to compare with; '' when none waits. */
78 anchor: string
79 /** Newest first, as git sorts them. */
80 branches: GitDiffBranch[]
81 base: string
82 compare: string
83 branchCompare: GitDiffBranchCompare | null
84 stat: GitDiffStat | null
85 /** What `stat` (and `patches`) were read for: selectionKey of the pick. */
86 statKey: string
87 statError: string
88 /** Files whose diff is open in the files view, in the order opened. */
89 open: string[]
90 patches: GitDiffPatch[]
91}
92
93declare module 'claude-code' {
94 interface PluginState {
95 'git-diff-timeline': { timeline: GitDiffTimeline }
96 }
97}
98