A VS Code style git graph pane: branch lanes, refs, commit details and diffs

A VS Code style git graph in a Claude Code pane: /git-graph opens it, /git-graph close closes it.
path:…)gh, read-only): PR number, state and checks on branches, CI result on commitsj/k next/prev, d diff, c compare, h HEAD, f search, r refresh⇔/⇕ grip in the header to resize; the size is rememberedEverything else is read-only: git runs with --no-optional-locks.
/plugin marketplace add DMKeyy/git-graph
/plugin install git-graph@git-graph
claude plugin validate .
claude plugin test .hooks/register.tsx 1154 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Detail, FileDiff, GitHub, Graph, GraphRow, Naming, PullRequest, Scope, Search, WorkTree } from '../types'
5import {
6 COLORS,
7 LOG_FORMAT,
8 ROW,
9 ago,
10 clip,
11 closeOver,
12 pills,
13 shortAgo,
14 shortAuthor,
15 shortSubject,
16 filterRows,
17 layout,
18 parseLog,
19 parseNumstat,
20 parsePrs,
21 parseRuns,
22 parseStashes,
23 parseStatus,
24 parseTracks,
25 readSearch,
26 rowCells,
27 rowSvg,
28 scopeRange,
29 splitPatch,
30} from './layout'
31
32const PANE = 'git-graph'
33const PAGE = 200
34const GITHUB_TTL = 120000
35
36const graph = atom({ plugin: 'git-graph', key: 'graph' } as const, null)
37const selected = atom({ plugin: 'git-graph', key: 'selected' } as const, null)
38const detail = atom({ plugin: 'git-graph', key: 'detail' } as const, null)
39const diff = atom({ plugin: 'git-graph', key: 'diff' } as const, null)
40const limit = atom({ plugin: 'git-graph', key: 'limit' } as const, PAGE)
41const scope = atom({ plugin: 'git-graph', key: 'scope' } as const, 'all')
42const query = atom({ plugin: 'git-graph', key: 'query' } as const, '')
43const search = atom({ plugin: 'git-graph', key: 'search' } as const, null)
44const compareBase = atom({ plugin: 'git-graph', key: 'compareBase' } as const, null)
45const claude = atom({ plugin: 'git-graph', key: 'claude' } as const, [])
46const github = atom({ plugin: 'git-graph', key: 'github' } as const, null)
47const isStashOpen = atom({ plugin: 'git-graph', key: 'isStashOpen' } as const, false)
48const naming = atom({ plugin: 'git-graph', key: 'naming' } as const, null)
49/** The first commit the desktop pane shows: it draws one page of commits at a time. */
50const offset = atom({ plugin: 'git-graph', key: 'offset' } as const, 0)
51const isHelpOpen = atom({ plugin: 'git-graph', key: 'isHelpOpen' } as const, false)
52const isMoreOpen = atom({ plugin: 'git-graph', key: 'isMoreOpen' } as const, false)
53
54/** The desktop pane goes blank past this many commit rows. */
55const DESKTOP_PAGE = 60
56
57
58const KEYS = {
59 size: 'git-graph:size',
60 scope: 'git-graph:scope',
61 stashes: 'git-graph:stashes-open',
62 claude: (repo: string) => `git-graph:claude:${repo}`,
63}
64
65/** Secondary text: light enough to read on the desktop's dark panes, where `dimColor` is too faint. */
66const MUTED = '#A9B1BD'
67
68const REF_STYLE = {
69 head: { bg: '#4FA3F7', fg: '#0B2440' },
70 branch: { bg: '#2BB3A3', fg: '#06302B' },
71 remote: { bg: '#B07CE8', fg: '#2A1145' },
72 tag: { bg: '#F5B400', fg: '#3D2A00' },
73} as const
74
75const CHECK_GLYPH = { pass: '✓', fail: '✗', pending: '●', none: '' } as const
76const CHECK_COLOR = { pass: 'success', fail: 'error', pending: 'warning', none: 'inactive' } as const
77
78// ---------------------------------------------------------------- git and gh (read-only)
79
80/** Read-only git: never takes the index lock, never runs repo hooks. */
81async function git($: EngineInterface, args: string[], cwd?: string) {
82 return $.process.run(['git', '--no-optional-locks', '-c', 'core.quotepath=off', ...args], {
83 cwd,
84 timeoutMs: 20000,
85 })
86}
87
88async function repoRoot($: EngineInterface): Promise<string | null> {
89 const top = await git($, ['rev-parse', '--show-toplevel'])
90 return top.exitCode === 0 ? top.stdout.trim() : null
91}
92
93async function headOf($: EngineInterface, root?: string): Promise<string | null> {
94 const head = await git($, ['rev-parse', '--verify', '-q', 'HEAD'], root)
95 return head.exitCode === 0 ? head.stdout.trim() : null
96}
97
98/** Changes whenever a ref moves: a commit, a fetch, a checkout, a new tag. */
99async function refSignature($: EngineInterface, root: string): Promise<string> {
100 return (await git($, ['show-ref', '--head'], root)).stdout
101}
102
103async function workTree($: EngineInterface, root?: string): Promise<WorkTree | null> {
104 const status = await git($, ['status', '--porcelain=v2', '--branch', '--untracked-files=normal'], root)
105 return status.exitCode === 0 ? parseStatus(status.stdout) : null
106}
107
108function narrowing(search: Search | null): { revs: string[]; tail: string[] } {
109 if (!search) return { revs: [], tail: [] }
110 if (search.mode === 'path') return { revs: [], tail: ['--', search.q] }
111 if (search.mode === 'hash') return { revs: [], tail: [] }
112 return { revs: ['-i', `--grep=${search.q}`, `--author=${search.q}`], tail: [] }
113}
114
115async function loadGraph(
116 $: EngineInterface,
117 root: string,
118 opts: { max: number; scope: string; search: Search | null },
119): Promise<Graph> {
120 const { search } = opts
121 let range = scopeRange(opts.scope)
122 if (search?.mode === 'hash') {
123 const found = await git($, ['rev-parse', '--verify', '-q', `${search.q}^{commit}`], root)
124 if (found.exitCode !== 0) {
125 return empty(root, `No commit matches ${search.q}.`)
126 }
127 range = [found.stdout.trim()]
128 }
129 const { revs, tail } = narrowing(search)
130
131 const [log, count, remoteList, head, refs, stashList, status] = await Promise.all([
132 git($, ['log', '--topo-order', `-n${opts.max}`, `--format=${LOG_FORMAT}`, ...revs, ...range, ...tail], root),
133 git($, ['rev-list', '--count', ...revs, ...range, ...tail], root),
134 git($, ['remote'], root),
135 headOf($, root),
136 git($, ['for-each-ref', '--format=%(refname:short)%09%(upstream:short)%09%(upstream:track)', 'refs/heads'], root),
137 git($, ['stash', 'list', '--format=%H%x1f%gd%x1f%s%x1f%at'], root),
138 workTree($, root),
139 ])
140 const tagList = await git($, ['tag', '--sort=-creatordate'], root)
141
142 if (log.exitCode !== 0) {
143 const isEmpty = /does not have any commits|unknown revision|bad default revision/.test(log.stderr)
144 return empty(root, isEmpty ? 'No commits yet.' : log.stderr.trim() || 'git log failed.')
145 }
146
147 const remotes = lines(remoteList.stdout)
148 const commits = parseLog(log.stdout)
149 const { rows, width } = layout(search ? closeOver(commits) : commits, remotes)
150 const tracks = parseTracks(refs.stdout)
151
152 return {
153 repo: root,
154 total: Number(count.stdout.trim()) || rows.length,
155 rows,
156 width,
157 head,
158 tracks,
159 branches: lines(refs.stdout).map(line => line.split('\t')[0] ?? '').filter(Boolean),
160 tags: lines(tagList.stdout).slice(0, 50),
161 work: status,
162 stashes: parseStashes(stashList.stdout),
163 }
164}
165
166function empty(repo: string, error: string): Graph {
167 return { repo, total: 0, rows: [], width: 1, head: null, tracks: {}, branches: [], tags: [], work: null, stashes: [], error }
168}
169
170function lines(text: string): string[] {
171 return text
172 .split('\n')
173 .map(line => line.trim())
174 .filter(Boolean)
175}
176
177/** Pull requests and CI runs through the `gh` CLI, read-only; any failure is kept as `error`. */
178async function loadGitHub($: EngineInterface, root: string, now: number): Promise<GitHub> {
179 const run = (args: string[]) => $.process.run(['gh', ...args], { cwd: root, timeoutMs: 25000 })
180 try {
181 const [prs, runs] = await Promise.all([
182 run(['pr', 'list', '--state', 'all', '--limit', '100', '--json', 'number,state,isDraft,headRefName,url,statusCheckRollup']),
183 run(['run', 'list', '--limit', '60', '--json', 'headSha,status,conclusion']),
184 ])
185 if (prs.exitCode !== 0) {
186 return { prs: {}, runs: {}, fetchedAt: now, error: prs.stderr.trim().split('\n')[0] || 'gh failed' }
187 }
188 return {
189 prs: parsePrs(prs.stdout),
190 runs: runs.exitCode === 0 ? parseRuns(runs.stdout) : {},
191 fetchedAt: now,
192 }
193 } catch (error) {
194 return { prs: {}, runs: {}, fetchedAt: now, error: String(error) }
195 }
196}
197
198const WORKTREE = 'WORKTREE'
199
200async function loadDetail(
201 $: EngineInterface,
202 root: string | undefined,
203 hash: string,
204 base: string | null,
205): Promise<Detail> {
206 if (hash === WORKTREE) {
207 const stat = await git($, ['diff', '--numstat', '-M', 'HEAD'], root)
208 return {
209 kind: 'worktree',
210 hash: WORKTREE,
211 short: 'working tree',
212 author: '',
213 email: '',
214 time: 0,
215 subject: 'Uncommitted changes',
216 body: '',
217 parents: [],
218 files: parseNumstat(stat.stdout),
219 }
220 }
221
222 const [meta, stat] = await Promise.all([
223 git($, ['show', '-s', '--format=%H%x1f%an%x1f%ae%x1f%at%x1f%P%x1f%s%x1f%b', hash], root),
224 base
225 ? git($, ['diff', '--numstat', '-M', base, hash], root)
226 : git($, ['show', '--numstat', '--format=', '-M', '--diff-merges=first-parent', hash], root),
227 ])
228 const [full, author, email, time, parents, subject, body] = meta.stdout.split('\x1f')
229 const id = (full ?? hash).trim()
230 return {
231 kind: base ? 'compare' : 'commit',
232 hash: id,
233 short: id.slice(0, 7),
234 base: base ?? undefined,
235 author: author ?? '',
236 email: email ?? '',
237 time: Number(time) || 0,
238 parents: (parents ?? '').split(' ').filter(Boolean),
239 subject: base ? `${base.slice(0, 7)}..${id.slice(0, 7)}` : (subject ?? ''),
240 body: base ? `Changes from ${base.slice(0, 7)} to ${id.slice(0, 7)}` : (body ?? '').trim(),
241 files: parseNumstat(stat.stdout),
242 }
243}
244
245async function loadPatch($: EngineInterface, root: string | undefined, det: Detail): Promise<FileDiff[]> {
246 const args =
247 det.kind === 'worktree'
248 ? ['diff', '-M', 'HEAD']
249 : det.kind === 'compare' && det.base
250 ? ['diff', '-M', det.base, det.hash]
251 : ['show', '--format=', '--patch', '-M', '--diff-merges=first-parent', det.hash]
252 const patch = await git($, args, root)
253 return splitPatch(patch.stdout, 40000)
254}
255
256// ---------------------------------------------------------------- loading
257
258let lastSignature = ''
259let isLoading = false
260
261async function load($: EngineInterface): Promise<void> {
262 if (isLoading) return
263 isLoading = true
264 try {
265 const root = await repoRoot($)
266 if (root === null) {
267 const error: Graph = {
268 repo: '',
269 total: 0,
270 rows: [],
271 width: 1,
272 head: null,
273 tracks: {},
274 branches: [],
275 tags: [],
276 work: null,
277 stashes: [],
278 error: 'This folder is not a git repository.',
279 }
280 await update($, graph, () => error)
281 return
282 }
283 const [max, which, narrowed] = await Promise.all([read($, limit), read($, scope), read($, search)])
284 const [next, sig] = await Promise.all([
285 loadGraph($, root, { max, scope: which, search: narrowed }),
286 refSignature($, root),
287 ])
288 lastSignature = sig
289 await update($, graph, () => next)
290 showStatus($, next)
291 $.ui.invalidate('ui.render')
292 await restoreClaude($, root)
293 void refreshGitHub($, root, false)
294 } finally {
295 isLoading = false
296 }
297}
298
299async function isOpen($: EngineInterface): Promise<boolean> {
300 return (await $.ui.panes()).some(pane => pane.id === PANE)
301}
302
303async function refreshIfChanged($: EngineInterface): Promise<void> {
304 if (isLoading || !(await isOpen($))) return
305 const root = await repoRoot($)
306 if (root === null) return
307 if ((await refSignature($, root)) !== lastSignature) await load($)
308}
309
310/** The working tree moves without any ref moving: kept fresh on its own, cheaply. */
311async function refreshWork($: EngineInterface): Promise<void> {
312 if (isLoading) return
313 const g = await read($, graph)
314 const work = await workTree($, g?.repo || undefined)
315 if (g && !g.error && JSON.stringify(work) !== JSON.stringify(g.work)) {
316 await update($, graph, current => (current ? { ...current, work } : current))
317 }
318 showStatus($, g ? { ...g, work } : null)
319}
320
321async function refreshGitHub($: EngineInterface, root: string, force: boolean): Promise<void> {
322 const now = await $.clock.now()
323 const current = await read($, github)
324 if (!force && current && now - current.fetchedAt < GITHUB_TTL) return
325 const next = await loadGitHub($, root, now)
326 await update($, github, () => next)
327}
328
329async function loadMore($: EngineInterface): Promise<void> {
330 const g = await read($, graph)
331 if (!g || isLoading || g.rows.length >= g.total) return
332 await update($, limit, n => n + PAGE)
333 await load($)
334}
335
336/** The status line: branch, ahead/behind its upstream, uncommitted files. */
337function showStatus($: EngineInterface, g: Graph | null): void {
338 const work = g?.work
339 if (!work) {
340 $.ui.status(undefined)
341 return
342 }
343 const changed = work.files.length
344 $.ui.status(
345 `⎇ ${work.branch}` +
346 (work.ahead ? ` ↑${work.ahead}` : '') +
347 (work.behind ? ` ↓${work.behind}` : '') +
348 (changed ? ` · ${changed} changed` : ''),
349 )
350}
351
352// ---------------------------------------------------------------- commits Claude made
353
354async function restoreClaude($: EngineInterface, root: string): Promise<void> {
355 const kept = await $.store.get(KEYS.claude(root))
356 if (Array.isArray(kept)) {
357 const hashes = kept.filter((h): h is string => typeof h === 'string')
358 await update($, claude, current => (current.length ? current : hashes))
359 }
360}
361
362async function recordClaudeCommit($: EngineInterface, hash: string): Promise<void> {
363 const root = await repoRoot($)
364 if (root === null) return
365 const next = await update($, claude, list => [hash, ...list.filter(h => h !== hash)].slice(0, 500))
366 await $.store.set(KEYS.claude(root), next)
367}
368
369// ---------------------------------------------------------------- selection and details
370
371async function select($: EngineInterface, hash: string): Promise<void> {
372 const [current, base] = await Promise.all([read($, selected), read($, compareBase)])
373 if (current === hash && !base) {
374 await clearSelection($)
375 return
376 }
377 await update($, selected, () => hash)
378 await update($, diff, () => null)
379 await update($, naming, () => null)
380
381 const root = (await read($, graph))?.repo || undefined
382 const comparing = base && base !== hash && hash !== WORKTREE ? base : null
383 const next = await loadDetail($, root, hash, comparing)
384 if ((await read($, selected)) === hash) await update($, detail, () => next)
385 $.ui.invalidate('ui.render')
386}
387
388async function clearSelection($: EngineInterface): Promise<void> {
389 await update($, selected, () => null)
390 await update($, detail, () => null)
391 await update($, diff, () => null)
392 await update($, naming, () => null)
393}
394
395async function toggleDiff($: EngineInterface, det: Detail): Promise<void> {
396 if ((await read($, diff)) !== null) {
397 await update($, diff, () => null)
398 return
399 }
400 const root = (await read($, graph))?.repo || undefined
401 const files = await loadPatch($, root, det)
402 await update($, diff, () => files)
403}
404
405function visibleRows(g: Graph, q: string): GraphRow[] {
406 return filterRows(g.rows, q)
407}
408
409async function step($: EngineInterface, by: 1 | -1): Promise<void> {
410 const [g, q, current] = await Promise.all([read($, graph), read($, query), read($, selected)])
411 if (!g) return
412 const rows = visibleRows(g, q)
413 if (rows.length === 0) return
414 const at = rows.findIndex(row => row.hash === current)
415 const index = Math.min(rows.length - 1, Math.max(0, at === -1 ? 0 : at + by))
416 const next = rows[index]
417 if (!next) return
418 await showIndex($, index)
419 await select($, next.hash)
420 await $.ui.scroll({ in: PANE, to: { key: `row-${next.hash}` } }).catch(() => undefined)
421}
422
423async function jumpTo($: EngineInterface, name: string): Promise<void> {
424 const g = await read($, graph)
425 if (!g) return
426 const row =
427 name === 'HEAD'
428 ? g.rows.find(r => r.hash === g.head)
429 : g.rows.find(r => r.refs.some(ref => ref.name === name || ref.name.endsWith(`/${name}`)))
430 if (!row) {
431 $.ui.toast(`${name} is not among the loaded commits. Load more, or pick it in the branch filter.`)
432 return
433 }
434 await showIndex($, visibleRows(g, await read($, query)).findIndex(r => r.hash === row.hash))
435 await $.ui.scroll({ in: PANE, to: { key: `row-${row.hash}` }, block: 'center' }).catch(() => undefined)
436 await select($, row.hash)
437}
438
439async function runSearch($: EngineInterface, text: string): Promise<void> {
440 const next: Search | null = text.trim() ? readSearch(text) : null
441 await update($, search, () => next)
442 await update($, query, () => '')
443 await update($, offset, () => 0)
444 await update($, limit, () => PAGE)
445 await clearSelection($)
446 await load($)
447}
448
449async function setScope($: EngineInterface, value: Scope): Promise<void> {
450 await update($, scope, () => value)
451 await $.store.set(KEYS.scope, value)
452 await update($, offset, () => 0)
453 await update($, limit, () => PAGE)
454 await load($)
455}
456
457// ---------------------------------------------------------------- git actions (they write: always asked first)
458
459async function confirmGit($: EngineInterface, what: string, args: string[]): Promise<void> {
460 let answer = ''
461 try {
462 answer = await $.ui.ask(`${what}? This runs: git ${args.join(' ')}`, {
463 options: ['Run', 'Cancel'],
464 header: 'Git',
465 })
466 } catch {
467 return
468 }
469 if (answer !== 'Run') return
470 const root = (await read($, graph))?.repo || undefined
471 const ran = await $.process.run(['git', ...args], { cwd: root, timeoutMs: 60000 })
472 if (ran.exitCode === 0) {
473 $.ui.toast(`git ${args[0]}: done`)
474 } else {
475 $.ui.toast(`git ${args[0]} failed: ${(ran.stderr || ran.stdout).trim().split('\n')[0] ?? ''}`, { timeoutMs: 10000 })
476 }
477 await load($)
478}
479
480function checkoutTarget(row: GraphRow | undefined, hash: string): string[] {
481 const local = row?.refs.find(ref => ref.kind === 'head' || ref.kind === 'branch')
482 if (local) return ['checkout', local.name]
483 const remote = row?.refs.find(ref => ref.kind === 'remote')
484 if (remote) return ['checkout', remote.name.slice(remote.name.indexOf('/') + 1)]
485 return ['checkout', '--detach', hash]
486}
487
488// ---------------------------------------------------------------- the pane's size
489
490type Size = { columns?: number; rows?: number }
491
492const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, Math.round(n)))
493
494/** What the pane measured when last drawn: the base a drag resizes from. */
495let drawn = { columns: 80, rows: 24, placement: 'dock' as 'dock' | 'inline', treeRows: 0 }
496
497async function savedSize($: EngineInterface): Promise<Size> {
498 const kept = await $.store.get(KEYS.size)
499 return kept && typeof kept === 'object' ? (kept as Size) : {}
500}
501
502async function openPane($: EngineInterface): Promise<void> {
503 await load($)
504 await $.ui.open({ id: PANE, title: 'Git graph', ...(await savedSize($)) })
505 $.ui.invalidate('ui.render')
506}
507
508/** A drag on the grip, in cells: wider to the left when docked, taller downwards inline. */
509async function resizeBy($: EngineInterface, dx: number, dy: number): Promise<void> {
510 const size = await savedSize($)
511 const next: Size =
512 drawn.placement === 'dock'
513 ? { ...size, columns: clamp(drawn.columns - dx, 40, 240) }
514 : { ...size, rows: clamp(drawn.rows + dy, 6, 120) }
515 await $.store.set(KEYS.size, next)
516 await $.ui.open({ id: PANE, title: 'Git graph', ...next })
517}
518
519async function opened($: EngineInterface): Promise<string> {
520 await openPane($)
521 const g = await read($, graph)
522 return g?.error ? `Git graph: ${g.error}` : `Git graph opened: ${g?.rows.length ?? 0} of ${g?.total ?? 0} commits.`
523}
524
525/** Moves the desktop's page so that the commit at `index` (among the visible rows) is on it. */
526async function showIndex($: EngineInterface, index: number): Promise<void> {
527 const start = Math.floor(Math.max(0, index) / DESKTOP_PAGE) * DESKTOP_PAGE
528 if ((await read($, offset)) !== start) await update($, offset, () => start)
529}
530
531// ---------------------------------------------------------------- hooks
532
533function isOurPane(requestId: string, title: string | undefined): boolean {
534 return requestId === PANE || requestId.includes(PANE) || title === 'Git graph'
535}
536
537export const register: Register = on => {
538 on('session.start', async ($, e, next) => {
539 await $.command.register({
540 name: 'git-graph',
541 description: 'Show the git graph of this repo in a pane (close: /git-graph close)',
542 argumentHint: '[close]',
543 immediate: true,
544 })
545 const [keptScope, keptStashes] = await Promise.all([$.store.get(KEYS.scope), $.store.get(KEYS.stashes)])
546 if (typeof keptScope === 'string') await update($, scope, () => keptScope)
547 if (typeof keptStashes === 'boolean') await update($, isStashOpen, () => keptStashes)
548
549 $.clock.every(4000, () => {
550 void refreshIfChanged($)
551 })
552 $.clock.every(6000, () => {
553 void refreshWork($)
554 })
555 void refreshWork($)
556 if (await isOpen($)) void load($)
557
558 return next(e)
559 })
560
561 on('command.run', { command: 'git-graph' }, async ($, e) => {
562 if ((e.args ?? '').trim() === 'close') {
563 await $.ui.close({ id: PANE })
564 return { text: 'Git graph closed.' }
565 }
566 return { text: await opened($) }
567 })
568
569 // The desktop app sends a slash command it does not list as a plain prompt:
570 // catch `/git-graph` there too, so it never reaches the model.
571 on('prompt.submit', async ($, e, next) => {
572 const typed = e.text.trim().match(/^\/git-graph(?:\s+(close))?$/)
573 if (!typed) return next(e)
574 if (typed[1] === 'close') {
575 await $.ui.close({ id: PANE })
576 return { drop: 'Git graph closed.' }
577 }
578 return { drop: await opened($) }
579 }).catch(($, e, next) => next(e))
580
581 // A commit Claude makes through its shell tools is marked in the graph.
582 on('tool.call', async ($, e, next) => {
583 const command = String((e as { command?: unknown }).command ?? '')
584 const isShell = e.tool === 'Bash' || e.tool === 'PowerShell'
585 if (!isShell || !/\bgit\b[\s\S]*\bcommit\b/.test(command)) return next(e)
586 const before = await headOf($)
587 const ran = await next(e)
588 const after = await headOf($)
589 if (after && after !== before) await recordClaudeCommit($, after)
590 void refreshIfChanged($)
591
592 return ran
593 }).catch(($, e, next) => next(e))
594
595 // Every write to this plugin's state redraws the pane: a surface that names
596 // the pane's instance otherwise (the desktop) does not redraw on the write alone.
597 on('state.set', { plugin: 'git-graph' }, async ($, e, next) => {
598 const written = await next(e)
599 $.ui.invalidate('ui.render')
600 return written
601 }).catch(($, e, next) => next(e))
602
603 on('ui.message', async ($, e, next) => {
604 if (e.element !== 'grip') return next(e)
605 const data = e.data as { dx?: unknown; dy?: unknown } | null
606 const dx = typeof data?.dx === 'number' ? data.dx : 0
607 const dy = typeof data?.dy === 'number' ? data.dy : 0
608 await resizeBy($, dx, dy)
609
610 return {}
611 })
612
613 // Scrolling near the end loads the next page of history.
614 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
615 const moved = await next(e)
616 if (e.by > 0 && e.offset + drawn.rows >= drawn.treeRows - 15) void loadMore($)
617
618 return moved
619 }).catch(($, e, next) => next(e))
620
621 on('turn.complete', async ($, e, next) => {
622 const ran = await next(e)
623 void refreshIfChanged($)
624 void refreshWork($)
625
626 return ran
627 })
628
629 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
630 // Matched by hand: a surface may name the pane's instance otherwise than its id.
631 if (!isOurPane(e.requestId, e.props.title)) return next(e)
632 const ui = $.ui.resolve(e)
633 const { Box, Text, Button, Code } = ui
634 // The desktop pane goes blank when it holds too many rows: it draws one page at a time.
635 const lite = e.surface === 'desktop'
636 const start = lite ? await read($, offset) : 0
637 const Svg = 'Svg' in ui ? ui.Svg : undefined
638 const Client = 'Client' in ui ? ui.Client : undefined
639 const Input = 'Input' in ui ? ui.Input : undefined
640 const Select = 'Select' in ui ? ui.Select : undefined
641
642 const [helpOpen, moreOpen] = await Promise.all([read($, isHelpOpen), read($, isMoreOpen)])
643 const [g, sel, det, files, which, q, narrowed, base, mine, gh, stashesOpen, askName] = await Promise.all([
644 read($, graph),
645 read($, selected),
646 read($, detail),
647 read($, diff),
648 read($, scope),
649 read($, query),
650 read($, search),
651 read($, compareBase),
652 read($, claude),
653 read($, github),
654 read($, isStashOpen),
655 read($, naming),
656 ])
657 const now = await $.clock.now()
658 const cols = Math.max(40, e.props.bodyColumns ?? 80)
659 drawn = { ...drawn, columns: e.props.bodyColumns ?? 80, rows: e.props.scroll?.bodyRows ?? 24, placement: e.props.placement }
660
661 if (g === null) {
662 return <Text color={MUTED}>Loading the git graph…</Text>
663 }
664
665 const byClaude = new Set(mine)
666 const prFor = (ref: string): PullRequest | undefined => gh?.prs[ref.replace(/^[^/]+\//, '')] ?? gh?.prs[ref]
667
668 // -------- header: title, filters, size grip
669 const scopeOptions = [
670 { value: 'all', label: 'All branches' },
671 { value: 'current', label: 'Current branch' },
672 { value: 'local', label: 'Local only (no remotes)' },
673 ...g.branches.slice(0, 40).map(b => ({ value: `branch:${b}`, label: `Branch: ${b}` })),
674 ]
675 const jumpOptions = [
676 { value: '', label: 'Jump to…' },
677 { value: 'HEAD', label: 'HEAD' },
678 ...g.branches.slice(0, 40).map(b => ({ value: b, label: b })),
679 ...g.tags.slice(0, 20).map(t => ({ value: t, label: `tag ${t}` })),
680 ]
681
682 const work0 = g.work
683 const header = (
684 <Box flexDirection="column" marginBottom={1}>
685 <Box flexDirection="row" alignItems="center" gap={1}>
686 <Text bold>{g.repo ? g.repo.split(/[\\/]/).pop() : 'Git graph'}</Text>
687 {work0?.branch ? (
688 <Text color="#4FA3F7">
689 {`⎇ ${work0.branch}${work0.ahead ? ` ↑${work0.ahead}` : ''}${work0.behind ? ` ↓${work0.behind}` : ''}`}
690 </Text>
691 ) : null}
692 <Box flexGrow={1} flexShrink={1} minWidth={0}>
693 <Text color={MUTED} wrap="truncate">
694 {g.rows.length ? `${g.rows.length === g.total ? g.total : `${g.rows.length} of ${g.total}`} commits` : ''}
695 {gh?.error ? ' · GitHub offline' : ''}
696 </Text>
697 </Box>
698 <Button key="refresh" plain label="⟳" onPress={() => load($)} />
699 <Button key="help" plain label="?" onPress={() => update($, isHelpOpen, open => !open).then(() => undefined)} />
700 {Client ? (
701 <Client key="grip" module="./grip.tsx" props={{ axis: e.props.placement === 'dock' ? 'x' : 'y' }} />
702 ) : null}
703 </Box>
704 <Box flexDirection="row" alignItems="center" gap={1} flexWrap="wrap">
705 {Select ? (
706 <Select
707 key="scope"
708 options={scopeOptions.some(o => o.value === which) ? scopeOptions : [...scopeOptions, { value: which, label: which }]}
709 value={which}
710 onSelect={value => setScope($, value)}
711 />
712 ) : (
713 <Button
714 key="scope"
715 label={which === 'all' ? 'All branches' : which === 'current' ? 'Current branch' : which}
716
717 onPress={() => setScope($, which === 'all' ? 'current' : which === 'current' ? 'local' : 'all')}
718 />
719 )}
720 {Select ? (
721 <Select key="jump" options={jumpOptions} value="" onSelect={value => (value ? jumpTo($, value) : undefined)} />
722 ) : (
723 <Button key="jump-head" label="Jump to HEAD" onPress={() => jumpTo($, 'HEAD')} />
724 )}
725 </Box>
726 {Input ? (
727 <Input
728 key="search"
729 placeholder="Filter commits… (Enter searches all history, path:… for a file)"
730 value={q}
731 submitLabel="search all"
732 onInput={value => update($, query, () => value).then(() => update($, offset, () => 0))}
733 onSubmit={value => runSearch($, value)}
734 />
735 ) : null}
736 {narrowed || q ? (
737 <Box flexDirection="row" gap={1} alignItems="center">
738 <Text color="suggestion">
739 {narrowed
740 ? `${narrowed.mode === 'path' ? 'History of' : narrowed.mode === 'hash' ? 'From commit' : 'Searching'} "${narrowed.q}"`
741 : `Filtering "${q}"`}
742 </Text>
743 <Text color={MUTED}>{`${narrowed ? g.total : filterRows(g.rows, q).length} found`}</Text>
744 <Button key="clear" plain label="✕ clear" onPress={() => runSearch($, '')} />
745 </Box>
746 ) : null}
747 {helpOpen ? (
748 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
749 <Text color={MUTED}>Keys (click the pane first): j/k next/prev · h HEAD · f search · r refresh · d diff · c compare</Text>
750 <Text color={MUTED}>Click a commit for its full message and details; click a file there to see its history.</Text>
751 <Text color={MUTED}>☁ in sync with origin · ↑ ahead · ↓ behind · ✻ made by Claude · ✓ ✗ ● CI</Text>
752 </Box>
753 ) : null}
754 </Box>
755 )
756
757 if (g.error) {
758 return (
759 <Box flexDirection="column">
760 {header}
761 <Text color="warning">{g.error}</Text>
762 </Box>
763 )
764 }
765
766 const graphCols = g.width * 2 + 1
767 const rows = visibleRows(g, q)
768
769 // -------- one commit row: graph slice, refs and badges, message, author, hover card
770 const commitRow = (row: GraphRow) => {
771 const badges = pills(row.refs).slice(0, 3)
772 const prs = new Map<number, PullRequest>()
773 for (const pill of badges) {
774 const pr = pill.branch ? prFor(pill.branch) : undefined
775 if (pr) prs.set(pr.number, pr)
776 }
777 const ci = prs.size === 0 ? gh?.runs[row.hash] : undefined
778 const isClaude = byClaude.has(row.hash)
779 const isSel = row.hash === sel || row.hash === base
780 const meta = `${shortAuthor(row.author)} · ${shortAgo(row.time, now)}`
781 const badgeChars = badges.reduce((n, p) => n + p.label.length + 5, 0) + prs.size * 7 + (isClaude ? 2 : 0) + (ci ? 2 : 0)
782 const room = cols - graphCols - badgeChars - meta.length - 6
783 const subject = clip(shortSubject(row.subject), Math.max(14, room))
784 const graphCell = Svg ? (
785 <Svg source={rowSvg(row, g.width)} alt={`commit ${row.short}`} height={ROW} />
786 ) : (
787 <Box flexDirection="row" flexShrink={0}>
788 {rowCells(row, g.width).map(cell => (
789 <Text color={COLORS[cell.color]}>{cell.glyph + ' '}</Text>
790 ))}
791 </Box>
792 )
793
794 return (
795 <Box
796 key={`row-${row.hash}`}
797 flexDirection="row"
798 alignItems="center"
799 backgroundColor={isSel ? 'rgba(79,163,247,0.18)' : undefined}
800 >
801 <Box flexShrink={0}>{graphCell}</Box>
802 <Box flexDirection="row" alignItems="center" gap={1} flexGrow={1} flexShrink={1} minWidth={0}>
803 {badges.map(pill => {
804 const track = pill.kind === 'head' || pill.kind === 'branch' ? g.tracks[pill.label] : undefined
805 const drift = track
806 ? `${track.ahead ? ` ↑${track.ahead}` : ''}${track.behind ? ` ↓${track.behind}` : ''}${track.isGone ? ' ⚠' : ''}`
807 : ''
808 const mark = drift || (pill.isSynced ? ' ☁' : '')
809 return (
810 <Text backgroundColor={REF_STYLE[pill.kind].bg} color={REF_STYLE[pill.kind].fg} bold={pill.kind === 'head'}>
811 {` ${pill.kind === 'head' ? '● ' : pill.kind === 'tag' ? '◆ ' : ''}${pill.label}${mark} `}
812 </Text>
813 )
814 })}
815 {[...prs.values()].map(pr => (
816 <Text color={pr.state === 'MERGED' ? 'merged' : pr.state === 'OPEN' ? 'success' : 'inactive'}>
817 {`#${pr.number}${pr.isDraft ? ' draft' : ''}${pr.checks !== 'none' ? ' ' + CHECK_GLYPH[pr.checks] : ''}`}
818 </Text>
819 ))}
820 <Box flexGrow={1} flexShrink={1} minWidth={0}>
821 <Button
822 key={`c-${row.hash}`}
823 plain
824 label={subject}
825 onPress={() => select($, row.hash)}
826 />
827 </Box>
828 {isClaude ? <Text color="claude">✻</Text> : null}
829 {ci && ci !== 'none' ? <Text color={CHECK_COLOR[ci]}>{CHECK_GLYPH[ci]}</Text> : null}
830 <Text color={MUTED} wrap="truncate">
831 {meta}
832 </Text>
833 </Box>
834 </Box>
835 )
836 }
837
838 // -------- the working tree and stashes, above the history
839 const work = g.work
840 const changed = work ? work.files.length : 0
841 const worktreeRow =
842 work && changed > 0 && (which === 'all' || which === 'current' || which === 'local') && !narrowed ? (
843 <Box
844 key={`row-${WORKTREE}`}
845 flexDirection="row"
846 alignItems="center"
847 gap={1}
848 backgroundColor={sel === WORKTREE ? 'rgba(79,163,247,0.18)' : undefined}
849 >
850 <Text color="warning">{' ◌ '.padEnd(graphCols)}</Text>
851 <Button
852 key="c-worktree"
853 plain
854 label={
855 'Uncommitted changes · ' +
856 [
857 work.staged ? `${work.staged} staged` : '',
858 work.modified ? `${work.modified} modified` : '',
859 work.untracked ? `${work.untracked} untracked` : '',
860 work.conflicted ? `${work.conflicted} conflicted` : '',
861 ]
862 .filter(Boolean)
863 .join(' · ')
864 }
865 onPress={() => select($, WORKTREE)}
866 />
867 </Box>
868 ) : null
869
870 const stashSection =
871 g.stashes.length > 0 && !narrowed ? (
872 <Box flexDirection="column">
873 <Button
874 key="stashes"
875 plain
876
877 label={`${stashesOpen ? '▾' : '▸'} Stashes (${g.stashes.length})`}
878 onPress={async () => {
879 const isOpenNow = await update($, isStashOpen, open => !open)
880 await $.store.set(KEYS.stashes, isOpenNow)
881 }}
882 />
883 {stashesOpen
884 ? g.stashes.map(stash => (
885 <Box key={`row-${stash.hash}`} flexDirection="row" gap={1}>
886 <Text color={MUTED}>{' ' + stash.ref}</Text>
887 <Button key={`c-${stash.hash}`} plain label={clip(stash.subject, cols - 30)} onPress={() => select($, stash.hash)} />
888 <Text color={MUTED}>{ago(stash.time, now)}</Text>
889 </Box>
890 ))
891 : null}
892 </Box>
893 ) : null
894
895 // -------- the details card
896 const card = det && sel && (det.hash === sel || det.hash.startsWith(sel)) ? detailCard(det) : sel ? <Text color={MUTED}>Loading…</Text> : null
897
898 function detailCard(d: Detail) {
899 const row = g?.rows.find(r => r.hash === d.hash)
900 const stash = g?.stashes.find(s => s.hash === d.hash)
901 const added = d.files.reduce((n, f) => n + f.added, 0)
902 const deleted = d.files.reduce((n, f) => n + f.deleted, 0)
903 const ask = (text: string) => () => $.prompt.fill({ text }).then(() => undefined)
904
905 return (
906 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1} marginBottom={1}>
907 <Box flexDirection="row" gap={1}>
908 <Box flexGrow={1} flexShrink={1} minWidth={0}>
909 <Text bold wrap="truncate">
910 {d.subject}
911 </Text>
912 </Box>
913 {d.kind === 'commit' ? <Text color={MUTED}>{d.short}</Text> : null}
914 </Box>
915 <Text color={MUTED}>
916 {d.kind === 'commit' ? `${d.author} · ${ago(d.time, now)} (${new Date(d.time * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC) · ` : ''}
917 {d.parents.length > 1 ? `merge of ${d.parents.length} parents · ` : ''}
918 {stash ? `${stash.ref} · ` : ''}
919 <Text color="diffAdded">+{added}</Text> <Text color="diffRemoved">−{deleted}</Text> · {d.files.length}{' '}
920 {d.files.length === 1 ? 'file' : 'files'}
921 {byClaude.has(d.hash) ? ' · made by Claude' : ''}
922 </Text>
923 {d.body ? <Text color={MUTED}>{clip(d.body, 600)}</Text> : null}
924
925 <Box flexDirection="column" marginTop={1}>
926 {d.files.slice(0, 10).map((f, i) => (
927 <Box flexDirection="row">
928 <Text color="diffAdded">{`+${f.added}`.padStart(5)}</Text>
929 <Text color="diffRemoved">{` −${f.deleted}`.padEnd(7)}</Text>
930 <Button key={`file-${i}`} plain label={clip(f.path, cols - 16)} onPress={() => runSearch($, `path:${f.path}`)} />
931 </Box>
932 ))}
933 {d.files.length > 10 ? <Text color={MUTED}>…and {d.files.length - 10} more (Show diff lists them all)</Text> : null}
934 {d.kind === 'worktree' && g?.work
935 ? g.work.files
936 .filter(f => f.code === '??')
937 .slice(0, 20)
938 .map(f => <Text color={MUTED}>{` new ${f.path}`}</Text>)
939 : null}
940 {d.files.length > 0 ? <Text color={MUTED}>Click a file to see its history.</Text> : <Text color={MUTED}>No file changes.</Text>}
941 </Box>
942
943 <Box flexDirection="row" gap={1} marginTop={1} flexWrap="wrap">
944 <Button key="diff" hotkey="d" label={files ? 'Hide diff' : 'Show diff'} onPress={() => toggleDiff($, d)} />
945 {d.kind === 'commit' ? (
946 <>
947 <Button key="ask" label="Explain" onPress={ask(`Explain commit ${d.short} ("${d.subject}"): what changed and why. Use git show ${d.short}.`)} />
948 <Button key="review" label="Review" onPress={ask(`Review commit ${d.short} ("${d.subject}") for bugs and risks. Use git show ${d.short}.`)} />
949 <Button
950 key="compare"
951 hotkey="c"
952 label="Compare with…"
953 onPress={async () => {
954 await update($, compareBase, () => d.hash)
955 $.ui.toast(`Comparing from ${d.short}: pick another commit`)
956 }}
957 />
958 <Button
959 key="copy"
960 label="Copy hash"
961 onPress={async press => {
962 const copied = await $.ui.copy({ text: d.hash, surface: press.surface })
963 if (copied.isCopied) {
964 $.ui.toast(`Copied ${d.short}`)
965 } else {
966 await $.prompt.fill({ text: d.hash, mode: 'insert' })
967 $.ui.toast('Hash put in the prompt box')
968 }
969 }}
970 />
971 </>
972 ) : null}
973 {d.kind === 'compare' ? (
974 <>
975 <Button key="notes" label="Release notes" onPress={ask(`Write release notes for ${d.subject}, grouped by theme. Use git log ${d.subject} and git diff --stat ${d.subject}.`)} />
976 <Button key="review-range" label="Review range" onPress={ask(`Review the changes in ${d.subject} for bugs and risks. Use git diff ${d.subject}.`)} />
977 </>
978 ) : null}
979 {d.kind === 'worktree' ? (
980 <>
981 <Button key="message" label="Write commit message" onPress={ask('Write a commit message for my uncommitted changes. Use git status and git diff HEAD. Do not commit.')} />
982 <Button key="review-work" label="Review my changes" onPress={ask('Review my uncommitted changes for bugs and risks. Use git diff HEAD. Do not change any file.')} />
983 </>
984 ) : null}
985 {base ? (
986 <Button
987 key="cancel-compare"
988 label="Stop comparing"
989
990 onPress={async () => {
991 await update($, compareBase, () => null)
992 await clearSelection($)
993 }}
994 />
995 ) : null}
996 {d.kind === 'commit' ? (
997 <Button
998 key="more"
999 plain
1000
1001 label={moreOpen ? 'Less ▾' : 'More ▸'}
1002 onPress={() => update($, isMoreOpen, open => !open).then(() => undefined)}
1003 />
1004 ) : null}
1005 <Button key="close-detail" plain label="✕" onPress={() => clearSelection($)} />
1006 </Box>
1007
1008 {d.kind === 'commit' && moreOpen ? (
1009 <Box flexDirection="column" marginTop={1}>
1010 <Box flexDirection="row" gap={1} flexWrap="wrap">
1011 <Button key="since" label="Summarize since this commit" onPress={ask(`Summarize what changed from ${d.short} to HEAD as release notes, grouped by theme. Use git log ${d.short}..HEAD.`)} />
1012 </Box>
1013 <Text color="warning">These change your repository; each asks before it runs:</Text>
1014 <Box flexDirection="row" gap={1} flexWrap="wrap">
1015 {stash ? (
1016 <Button key="a-apply" label="Apply stash" onPress={() => confirmGit($, `Apply ${stash.ref}`, ['stash', 'apply', stash.ref])} />
1017 ) : (
1018 <>
1019 <Button key="a-checkout" label="Checkout" onPress={() => {
1020 const args = checkoutTarget(row, d.hash)
1021 return confirmGit($, `Check out ${args[args.length - 1]}`, args)
1022 }} />
1023 <Button key="a-branch" label="New branch…" onPress={() => update($, naming, () => 'branch' as Naming).then(() => undefined)} />
1024 <Button key="a-tag" label="Tag…" onPress={() => update($, naming, () => 'tag' as Naming).then(() => undefined)} />
1025 <Button key="a-pick" label="Cherry-pick" onPress={() => confirmGit($, `Cherry-pick ${d.short} onto the current branch`, d.parents.length > 1 ? ['cherry-pick', '-m', '1', d.hash] : ['cherry-pick', d.hash])} />
1026 <Button key="a-revert" label="Revert" onPress={() => confirmGit($, `Revert ${d.short} with a new commit`, d.parents.length > 1 ? ['revert', '--no-edit', '-m', '1', d.hash] : ['revert', '--no-edit', d.hash])} />
1027 </>
1028 )}
1029 </Box>
1030 {askName && Input ? (
1031 <Input
1032 key="name"
1033 label={askName === 'branch' ? `New branch at ${d.short}` : `Tag ${d.short} as`}
1034 placeholder={askName === 'branch' ? 'feat/my-branch' : 'v1.0.0'}
1035 submitLabel="create"
1036 autoFocus
1037 onSubmit={async value => {
1038 const name = value.trim()
1039 await update($, naming, () => null)
1040 if (!name) return
1041 await confirmGit(
1042 $,
1043 askName === 'branch' ? `Create branch ${name} at ${d.short}` : `Tag ${d.short} as ${name}`,
1044 askName === 'branch' ? ['branch', name, d.hash] : ['tag', name, d.hash],
1045 )
1046 }}
1047 />
1048 ) : null}
1049 </Box>
1050 ) : null}
1051
1052 {files ? (
1053 <Box flexDirection="column" marginTop={1}>
1054 {files.length === 0 ? <Text color={MUTED}>No textual changes.</Text> : null}
1055 {files.map(file => (
1056 <Box flexDirection="column" marginBottom={1}>
1057 <Text bold>{file.path}</Text>
1058 {file.hunks ? (
1059 <Code source={file.hunks} format="diff" path={file.path} wrap="truncate-end" />
1060 ) : (
1061 <Text color={MUTED}>Binary, or a rename only.</Text>
1062 )}
1063 </Box>
1064 ))}
1065 </Box>
1066 ) : null}
1067 </Box>
1068 )
1069 }
1070
1071 const compareHint = base && !det ? (
1072 <Box flexDirection="row" gap={1} marginBottom={1}>
1073 <Text color="suggestion">Comparing from {base.slice(0, 7)}: pick another commit.</Text>
1074 <Button key="cancel-compare-hint" label="Cancel" onPress={() => update($, compareBase, () => null).then(() => undefined)} />
1075 </Box>
1076 ) : null
1077
1078 const more =
1079 g.rows.length < g.total && !q && !lite ? (
1080 <Box marginTop={1}>
1081 <Button key="more" label={`Load ${Math.min(PAGE, g.total - g.rows.length)} more`} onPress={() => loadMore($)} />
1082 </Box>
1083 ) : null
1084
1085 drawn.treeRows = rows.length + 8 + (card ? 20 + (det?.files.length ?? 0) : 0) + (stashesOpen ? g.stashes.length : 0)
1086
1087 return (
1088 <Box flexDirection="column">
1089 {header}
1090 {compareHint}
1091 {card}
1092 {worktreeRow}
1093 {stashSection}
1094 {rows.length === 0 ? (
1095 <Box flexDirection="column" marginTop={1}>
1096 <Text>No commit matches.</Text>
1097 <Text color={MUTED}>{q ? 'Press Enter to search the whole history instead of the loaded commits.' : 'Try another branch filter.'}</Text>
1098 </Box>
1099 ) : null}
1100 <Box flexDirection="column">{(lite ? rows.slice(start, start + DESKTOP_PAGE) : rows).map(commitRow)}</Box>
1101 {lite && rows.length > DESKTOP_PAGE ? (
1102 <Box flexDirection="row" gap={1} alignItems="center" marginTop={1}>
1103 {start > 0 ? (
1104 <Button key="newer" label="◂ Newer" onPress={() => update($, offset, n => Math.max(0, n - DESKTOP_PAGE)).then(() => undefined)} />
1105 ) : null}
1106 <Text color={MUTED}>
1107 {start + 1}–{Math.min(start + DESKTOP_PAGE, rows.length)} of {rows.length}
1108 </Text>
1109 {start + DESKTOP_PAGE < rows.length ? (
1110 <Button key="older" label="Older ▸" onPress={() => update($, offset, n => n + DESKTOP_PAGE).then(() => undefined)} />
1111 ) : g.rows.length < g.total && !q ? (
1112 <Button
1113 key="older-load"
1114 label="Older ▸"
1115 onPress={async () => {
1116 await loadMore($)
1117 await update($, offset, n => n + DESKTOP_PAGE)
1118 }}
1119 />
1120 ) : null}
1121 </Box>
1122 ) : null}
1123 {more}
1124 <Box flexDirection="row" gap={1} marginTop={1}>
1125 <Button key="k-next" plain hotkey="j" label="next" onPress={() => step($, 1)} />
1126 <Button key="k-prev" plain hotkey="k" label="prev" onPress={() => step($, -1)} />
1127 <Button key="k-head" plain hotkey="h" label="HEAD" onPress={() => jumpTo($, 'HEAD')} />
1128 {Input ? (
1129 <Button key="k-find" plain hotkey="f" label="search" onPress={() => $.ui.focus({ requestId: PANE, key: 'search' }).then(() => undefined)} />
1130 ) : null}
1131 <Button key="k-refresh" plain hotkey="r" label="refresh" onPress={() => load($)} />
1132 </Box>
1133 </Box>
1134 )
1135 }).catch(($, e, next) => {
1136 // A tree that threw, ran out of time, or that the surface refused: say why in the pane.
1137 const { Box, Text, Button } = $.ui.resolve(e)
1138 const failure = next.error
1139 return (
1140 <Box flexDirection="column">
1141 <Text color="error">git-graph could not draw ({failure?.kind ?? 'unknown'}):</Text>
1142 <Text>{failure?.message ?? 'no reason given'}</Text>
1143 <Button key="retry" label="Retry" onPress={() => load($)} />
1144 </Box>
1145 )
1146 })
1147
1148 // A Client (the resize grip) that failed on a surface: say so, then draw without it.
1149 on('ui.fault', async ($, e, next) => {
1150 $.ui.toast(`git-graph: ${e.reason}`, { timeoutMs: 10000 })
1151 return next(e)
1152 })
1153}
1154hooks/layout.ts 480 lines1import type {
2 Checks,
3 FileDiff,
4 FileStat,
5 GraphRow,
6 PullRequest,
7 Ref,
8 RefKind,
9 Search,
10 Seg,
11 Stash,
12 Track,
13 WorkTree,
14} from '../types'
15
16export const COLORS = [
17 '#4FA3F7',
18 '#F5B400',
19 '#E5437A',
20 '#2BB3A3',
21 '#B07CE8',
22 '#E8743B',
23 '#7CB342',
24 '#F06292',
25]
26
27export const LANE = 14
28export const ROW = 24
29
30const FS = '\x1f'
31const RS = '\x1e'
32
33/** The `--format` the log is read with: hash, parents, author, time, refs, subject. */
34export const LOG_FORMAT = ['%H', '%P', '%an', '%at', '%D', '%s'].join('%x1f') + '%x1e'
35
36export type RawCommit = {
37 hash: string
38 parents: string[]
39 author: string
40 time: number
41 refs: string
42 subject: string
43}
44
45export function parseLog(stdout: string): RawCommit[] {
46 return stdout
47 .split(RS)
48 .map(record => record.replace(/^\s+/, ''))
49 .filter(record => record.length > 0)
50 .map(record => {
51 const [hash, parents, author, time, refs, subject] = record.split(FS)
52 return {
53 hash: hash ?? "",
54 parents: parents ? parents.split(' ').filter(Boolean) : [],
55 author: author ?? '',
56 time: Number(time) || 0,
57 refs: refs ?? '',
58 subject: subject ?? '',
59 }
60 })
61}
62
63export function parseRefs(decoration: string, remotes: readonly string[]): Ref[] {
64 const refs: Ref[] = []
65 for (const part of decoration.split(', ').map(p => p.trim()).filter(Boolean)) {
66 if (part.startsWith('HEAD -> ')) {
67 refs.push({ name: part.slice(8), kind: 'head' })
68 } else if (part === 'HEAD') {
69 refs.push({ name: 'HEAD', kind: 'head' })
70 } else if (part.startsWith('tag: ')) {
71 refs.push({ name: part.slice(5), kind: 'tag' })
72 } else if (part.endsWith('/HEAD')) {
73 continue
74 } else if (remotes.some(remote => part.startsWith(remote + '/'))) {
75 refs.push({ name: part, kind: 'remote' })
76 } else {
77 refs.push({ name: part, kind: 'branch' })
78 }
79 }
80 return refs
81}
82
83/**
84 * Lays commits (newest first, topological order) out in lanes, VS Code style:
85 * each row carries the strokes of its own slice, so rows draw independently.
86 */
87export function layout(
88 commits: readonly RawCommit[],
89 remotes: readonly string[] = [],
90): { rows: GraphRow[]; width: number } {
91 const lanes: (string | null)[] = []
92 const laneColor: number[] = []
93 let nextColor = 0
94 let width = 1
95 const firstFree = () => {
96 const free = lanes.indexOf(null)
97 return free === -1 ? lanes.length : free
98 }
99
100 const rows = commits.map(commit => {
101 const before = lanes.slice()
102 const beforeColor = laneColor.slice()
103 const segs: Seg[] = []
104
105 let lane = lanes.indexOf(commit.hash)
106 if (lane === -1) {
107 lane = firstFree()
108 lanes[lane] = commit.hash
109 laneColor[lane] = nextColor++ % COLORS.length
110 }
111 const color = laneColor[lane] ?? 0
112
113 before.forEach((hash, k) => {
114 if (hash === null || hash === undefined) return
115 if (hash === commit.hash) {
116 segs.push({ x1: k, y1: 0, x2: lane, y2: 1, color: beforeColor[k] ?? 0 })
117 if (k !== lane) lanes[k] = null
118 } else {
119 segs.push({ x1: k, y1: 0, x2: k, y2: 2, color: beforeColor[k] ?? 0 })
120 }
121 })
122
123 const [first, ...rest] = commit.parents
124 lanes[lane] = first ?? null
125 if (first) segs.push({ x1: lane, y1: 1, x2: lane, y2: 2, color })
126
127 for (const parent of rest) {
128 let target = lanes.indexOf(parent)
129 if (target === -1) {
130 target = firstFree()
131 lanes[target] = parent
132 laneColor[target] = nextColor++ % COLORS.length
133 }
134 segs.push({ x1: lane, y1: 1, x2: target, y2: 2, color: laneColor[target] ?? 0 })
135 }
136
137 width = Math.max(width, before.length, lanes.length)
138 while (lanes.length > 0 && lanes[lanes.length - 1] === null) {
139 lanes.pop()
140 }
141
142 return {
143 hash: commit.hash,
144 short: commit.hash.slice(0, 7),
145 subject: commit.subject,
146 author: commit.author,
147 time: commit.time,
148 refs: parseRefs(commit.refs, remotes),
149 lane,
150 color,
151 isMerge: commit.parents.length > 1,
152 segs,
153 }
154 })
155
156 return { rows, width }
157}
158
159const laneX = (k: number) => 8 + k * LANE
160const rowY = (y: 0 | 1 | 2) => (y * ROW) / 2
161
162/** One row's slice of the graph as an SVG document. */
163export function rowSvg(row: GraphRow, width: number): string {
164 const w = 16 + (width - 1) * LANE
165 const paths = row.segs.map(seg => {
166 const x1 = laneX(seg.x1)
167 const x2 = laneX(seg.x2)
168 const y1 = rowY(seg.y1)
169 const y2 = rowY(seg.y2)
170 const mid = (y1 + y2) / 2
171 const d =
172 x1 === x2
173 ? `M${x1} ${y1}V${y2}`
174 : `M${x1} ${y1}C${x1} ${mid} ${x2} ${mid} ${x2} ${y2}`
175 return `<path d="${d}" stroke="${COLORS[seg.color]}" stroke-width="2" fill="none"/>`
176 })
177 const cx = laneX(row.lane)
178 const fill = COLORS[row.color]
179 const dot = row.isMerge
180 ? `<circle cx="${cx}" cy="12" r="5" fill="${fill}"/><circle cx="${cx}" cy="12" r="2.2" fill="#ffffff"/>`
181 : `<circle cx="${cx}" cy="12" r="4" fill="${fill}"/>`
182 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${ROW}" viewBox="0 0 ${w} ${ROW}">${paths.join('')}${dot}</svg>`
183}
184
185export type Cell = { glyph: string; color: number }
186
187/** One row's slice of the graph as terminal cells, for surfaces without Svg. */
188export function rowCells(row: GraphRow, width: number): Cell[] {
189 const cells: Cell[] = []
190 for (let k = 0; k < width; k++) {
191 if (k === row.lane) {
192 cells.push({ glyph: row.isMerge ? '◉' : '●', color: row.color })
193 continue
194 }
195 const through = row.segs.find(s => s.x1 === k && s.x2 === k)
196 const joins = row.segs.find(s => s.x1 === k && s.x2 !== k)
197 const forks = row.segs.find(s => s.x2 === k && s.x1 !== k)
198 if (through) cells.push({ glyph: '│', color: through.color })
199 else if (joins) cells.push({ glyph: '╯', color: joins.color })
200 else if (forks) cells.push({ glyph: '╮', color: forks.color })
201 else cells.push({ glyph: ' ', color: 0 })
202 }
203 return cells
204}
205
206export function parseNumstat(stdout: string): FileStat[] {
207 return stdout
208 .split('\n')
209 .map(line => line.split('\t'))
210 .filter(parts => parts.length >= 3)
211 .map(([added, deleted, ...path]) => ({
212 path: path.join('\t'),
213 added: Number(added) || 0,
214 deleted: Number(deleted) || 0,
215 }))
216}
217
218/** Splits `git show --format= --patch` output into per-file hunks, bounded. */
219export function splitPatch(patch: string, maxChars = 60000, maxFiles = 20): FileDiff[] {
220 const files: FileDiff[] = []
221 let used = 0
222 for (const chunk of patch.split(/^diff --git /m).slice(1)) {
223 if (files.length >= maxFiles || used >= maxChars) break
224 const header = chunk.slice(0, chunk.indexOf('\n'))
225 const plus = chunk.match(/^\+\+\+ b\/(.*)$/m)
226 const path = plus?.[1] ?? header.replace(/^a\/(.*) b\/.*$/, '$1')
227 const at = chunk.search(/^@@/m)
228 if (at === -1) {
229 files.push({ path, hunks: '' })
230 continue
231 }
232 let hunks = chunk.slice(at).replace(/\n$/, '')
233 if (used + hunks.length > maxChars) {
234 const cut = hunks.lastIndexOf('\n@@', maxChars - used)
235 if (cut <= 0) break
236 hunks = hunks.slice(0, cut)
237 }
238 used += hunks.length
239 files.push({ path, hunks })
240 }
241 return files
242}
243
244export function ago(seconds: number, now: number): string {
245 const s = Math.max(0, Math.round(now / 1000 - seconds))
246 if (s < 60) return 'just now'
247 const m = Math.round(s / 60)
248 if (m < 60) return `${m}m ago`
249 const h = Math.round(m / 60)
250 if (h < 48) return `${h}h ago`
251 const d = Math.round(h / 24)
252 if (d < 60) return `${d}d ago`
253 const mo = Math.round(d / 30)
254 if (mo < 24) return `${mo}mo ago`
255 return `${Math.round(mo / 12)}y ago`
256}
257
258export function clip(text: string, max: number): string {
259 return text.length <= max ? text : text.slice(0, Math.max(1, max - 1)) + '…'
260}
261
262/** Keeps only the parents a result holds, so a filtered log draws no lanes into nowhere. */
263export function closeOver(commits: readonly RawCommit[]): RawCommit[] {
264 const present = new Set(commits.map(c => c.hash))
265 return commits.map(c => ({ ...c, parents: c.parents.filter(p => present.has(p)) }))
266}
267
268/** `git status --porcelain=v2 --branch`. */
269export function parseStatus(stdout: string): WorkTree {
270 const work: WorkTree = {
271 branch: '',
272 ahead: 0,
273 behind: 0,
274 staged: 0,
275 modified: 0,
276 untracked: 0,
277 conflicted: 0,
278 files: [],
279 }
280 for (const line of stdout.split('\n')) {
281 if (line.startsWith('# branch.head ')) {
282 work.branch = line.slice(14).trim()
283 } else if (line.startsWith('# branch.ab ')) {
284 const m = line.match(/\+(\d+) -(\d+)/)
285 work.ahead = Number(m?.[1] ?? 0)
286 work.behind = Number(m?.[2] ?? 0)
287 } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
288 const parts = line.split(' ')
289 const xy = parts[1] ?? '..'
290 const rest = parts.slice(line.startsWith('1 ') ? 8 : 9).join(' ')
291 const path = rest.split('\t')[0] ?? rest
292 if (xy[0] !== '.') work.staged++
293 if (xy[1] !== '.') work.modified++
294 work.files.push({ path, code: xy.replace(/\./g, ' ') })
295 } else if (line.startsWith('u ')) {
296 work.conflicted++
297 work.files.push({ path: line.split(' ').slice(10).join(' '), code: 'UU' })
298 } else if (line.startsWith('? ')) {
299 work.untracked++
300 work.files.push({ path: line.slice(2), code: '??' })
301 }
302 }
303 return work
304}
305
306/** `git for-each-ref --format=%(refname:short)%09%(upstream:short)%09%(upstream:track) refs/heads`. */
307export function parseTracks(stdout: string): Record<string, Track> {
308 const tracks: Record<string, Track> = {}
309 for (const line of stdout.split('\n')) {
310 const [name, upstream, track] = line.split('\t')
311 if (!name || !upstream) continue
312 tracks[name] = {
313 upstream,
314 ahead: Number(track?.match(/ahead (\d+)/)?.[1] ?? 0),
315 behind: Number(track?.match(/behind (\d+)/)?.[1] ?? 0),
316 isGone: (track ?? '').includes('gone'),
317 }
318 }
319 return tracks
320}
321
322/** `git stash list --format=%H%x1f%gd%x1f%s%x1f%at`. */
323export function parseStashes(stdout: string): Stash[] {
324 return stdout
325 .split('\n')
326 .map(line => line.split(FS))
327 .filter(parts => parts.length >= 4 && parts[0])
328 .map(([hash, ref, subject, time]) => ({
329 hash: hash ?? '',
330 ref: ref ?? '',
331 subject: subject ?? '',
332 time: Number(time) || 0,
333 }))
334}
335
336type CheckRun = { conclusion?: string | null; status?: string | null; state?: string | null }
337
338export function rollup(checks: readonly CheckRun[] | null | undefined): Checks {
339 if (!checks || checks.length === 0) return 'none'
340 const bad = ['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE']
341 if (checks.some(c => bad.includes((c.conclusion ?? c.state ?? '').toUpperCase()))) return 'fail'
342 if (
343 checks.some(
344 c =>
345 (c.status && c.status.toUpperCase() !== 'COMPLETED') ||
346 ['PENDING', 'EXPECTED'].includes((c.state ?? '').toUpperCase()),
347 )
348 ) {
349 return 'pending'
350 }
351 return 'pass'
352}
353
354/** `gh pr list --json number,state,isDraft,headRefName,url,statusCheckRollup`, newest first. */
355export function parsePrs(stdout: string): Record<string, PullRequest> {
356 const prs: Record<string, PullRequest> = {}
357 const list = JSON.parse(stdout) as Array<{
358 number: number
359 state: PullRequest['state']
360 isDraft: boolean
361 headRefName: string
362 url: string
363 statusCheckRollup?: CheckRun[]
364 }>
365 for (const pr of list) {
366 if (prs[pr.headRefName]) continue
367 prs[pr.headRefName] = {
368 number: pr.number,
369 state: pr.state,
370 isDraft: pr.isDraft,
371 checks: rollup(pr.statusCheckRollup),
372 url: pr.url,
373 }
374 }
375 return prs
376}
377
378/** `gh run list --json headSha,status,conclusion`, newest first: each commit's latest run. */
379export function parseRuns(stdout: string): Record<string, Checks> {
380 const runs: Record<string, Checks> = {}
381 const list = JSON.parse(stdout) as Array<{ headSha: string; status: string; conclusion: string }>
382 for (const run of list) {
383 if (runs[run.headSha]) continue
384 runs[run.headSha] =
385 run.status !== 'completed'
386 ? 'pending'
387 : ['success', 'skipped', 'neutral'].includes(run.conclusion)
388 ? 'pass'
389 : 'fail'
390 }
391 return runs
392}
393
394/** A search box entry read as git would narrow on it: a commit, a path, or text. */
395export function readSearch(q: string): Search {
396 const text = q.trim()
397 if (text.startsWith('path:')) return { q: text.slice(5).trim(), mode: 'path' }
398 if (/^[0-9a-f]{7,40}$/i.test(text)) return { q: text, mode: 'hash' }
399 if (/[\\/]/.test(text) || /^[\w.-]+\.[a-z0-9]{1,5}$/i.test(text)) return { q: text, mode: 'path' }
400 return { q: text, mode: 'text' }
401}
402
403/** The rows a live filter keeps: message, author, hash or ref name. */
404export function filterRows(rows: readonly GraphRow[], query: string): GraphRow[] {
405 const q = query.trim().toLowerCase()
406 if (!q) return rows.slice()
407 return rows.filter(
408 row =>
409 row.subject.toLowerCase().includes(q) ||
410 row.author.toLowerCase().includes(q) ||
411 row.hash.startsWith(q) ||
412 row.refs.some(ref => ref.name.toLowerCase().includes(q)),
413 )
414}
415
416/** The git arguments a scope selects. */
417export function scopeRange(scope: string): string[] {
418 if (scope === 'current') return ['HEAD']
419 if (scope === 'local') return ['--branches', '--tags', 'HEAD']
420 if (scope.startsWith('branch:')) return [scope.slice(7)]
421 return ['--exclude=refs/stash', '--all']
422}
423
424/** One badge on a commit row: a local branch absorbs its `origin/` twin. */
425export type Pill = { label: string; kind: RefKind; branch?: string; isSynced: boolean }
426
427/** Folds each local branch with the remote branch of the same name on the same commit. */
428export function pills(refs: readonly Ref[]): Pill[] {
429 const locals = new Set(refs.filter(r => r.kind === 'head' || r.kind === 'branch').map(r => r.name))
430 const absorbed = new Set<string>()
431 const out: Pill[] = []
432 for (const ref of refs) {
433 if (ref.kind === 'head' || ref.kind === 'branch') {
434 const twin = refs.find(r => r.kind === 'remote' && r.name.slice(r.name.indexOf('/') + 1) === ref.name)
435 if (twin) absorbed.add(twin.name)
436 out.push({ label: ref.name, kind: ref.kind, branch: ref.name, isSynced: Boolean(twin) })
437 }
438 }
439 for (const ref of refs) {
440 if (ref.kind === 'remote' && !absorbed.has(ref.name)) {
441 const branch = ref.name.slice(ref.name.indexOf('/') + 1)
442 out.push({ label: ref.name, kind: 'remote', branch: locals.has(branch) ? undefined : branch, isSynced: false })
443 }
444 }
445 for (const ref of refs) {
446 if (ref.kind === 'tag') out.push({ label: ref.name, kind: 'tag', isSynced: false })
447 }
448 return out
449}
450
451/** A merge message cut to what it says: `Merge #40 ← feat/pdf`, `Merge main → fix/x`. */
452export function shortSubject(subject: string): string {
453 const pr = subject.match(/^Merge pull request #(\d+) from [^/\s]+\/(\S+)/)
454 if (pr) return `Merge #${pr[1]} ← ${pr[2]}`
455 const branch = subject.match(/^Merge branch '([^']+)'(?: of \S+)?(?: into (\S+))?/)
456 if (branch) return branch[2] ? `Merge ${branch[1]} → ${branch[2]}` : `Merge ${branch[1]}`
457 const remote = subject.match(/^Merge remote-tracking branch '([^']+)'(?: into (\S+))?/)
458 if (remote) return remote[2] ? `Merge ${remote[1]} → ${remote[2]}` : `Merge ${remote[1]}`
459 return subject
460}
461
462/** `now`, `5m`, `2h`, `3d`, `5w`, `4mo`, `2y`. */
463export function shortAgo(seconds: number, now: number): string {
464 const s = Math.max(0, Math.round(now / 1000 - seconds))
465 if (s < 60) return 'now'
466 if (s < 3600) return `${Math.round(s / 60)}m`
467 if (s < 86400) return `${Math.round(s / 3600)}h`
468 if (s < 86400 * 14) return `${Math.round(s / 86400)}d`
469 if (s < 86400 * 60) return `${Math.round(s / (86400 * 7))}w`
470 if (s < 86400 * 365) return `${Math.round(s / (86400 * 30))}mo`
471 return `${Math.round(s / (86400 * 365))}y`
472}
473
474/** `Haiouani Anis` → `Haiouani A.`; one word stays whole. */
475export function shortAuthor(name: string): string {
476 const words = name.trim().split(/\s+/)
477 if (words.length < 2) return clip(name, 14)
478 return clip(`${words[0]} ${words[words.length - 1]?.[0] ?? ''}.`, 14)
479}
480hooks/grip.tsx 53 lines1import type { ClientModule, ClientPointerEvent } from 'claude-code'
2
3/** What the grip draws: which way a drag resizes the pane. */
4export type GripProps = { axis: 'x' | 'y' }
5
6type Drag = { x0: number; y0: number; dx: number; dy: number } | null
7
8/**
9 * A drag handle drawn inside the pane: press, drag, release. On release it
10 * posts the distance moved, in cells, and the hooks module resizes the pane.
11 */
12const Grip: ClientModule<GripProps, { drag: Drag; hover: boolean; isWired: boolean }> = (props, surface) => {
13 const { Box, Text } = surface.elements
14 const state = surface.state ?? { drag: null, hover: false, isWired: false }
15
16 if (!state.isWired) {
17 surface.onPointer((e: ClientPointerEvent) => {
18 const now = surface.state ?? state
19 if (e.type === 'enter' || e.type === 'leave') {
20 surface.setState({ ...now, hover: e.type === 'enter' })
21 } else if (e.type === 'down' && e.button === 'left') {
22 surface.setState({ ...now, drag: { x0: e.x, y0: e.y, dx: 0, dy: 0 } })
23 } else if (e.type === 'move' && now.drag) {
24 surface.setState({ ...now, drag: { ...now.drag, dx: e.x - now.drag.x0, dy: e.y - now.drag.y0 } })
25 } else if (e.type === 'up' && now.drag) {
26 const { dx, dy } = now.drag
27 surface.setState({ ...now, drag: null })
28 if (dx !== 0 || dy !== 0) surface.post({ dx, dy })
29 }
30 })
31 surface.setState({ ...state, isWired: true })
32 }
33
34 const delta = props.axis === 'x' ? state.drag?.dx : state.drag?.dy
35 const label = state.drag
36 ? props.axis === 'x'
37 ? `⇔ ${delta && delta < 0 ? '+' : delta && delta > 0 ? '−' : ''}${Math.abs(delta ?? 0)} col`
38 : `⇕ ${delta && delta > 0 ? '+' : delta && delta < 0 ? '−' : ''}${Math.abs(delta ?? 0)} rows`
39 : props.axis === 'x'
40 ? '⇔ drag'
41 : '⇕ drag'
42
43 return (
44 <Box>
45 <Text color={state.drag || state.hover ? 'suggestion' : '#A9B1BD'}>
46 {label}
47 </Text>
48 </Box>
49 )
50}
51
52export default Grip
53types/index.d.ts 123 lines1export type RefKind = 'head' | 'branch' | 'remote' | 'tag'
2
3export type Ref = { name: string; kind: RefKind }
4
5/** One stroke of a row's slice of the graph: lanes x1 to x2, rows 0 top, 1 middle, 2 bottom. */
6export type Seg = { x1: number; y1: 0 | 1 | 2; x2: number; y2: 0 | 1 | 2; color: number }
7
8export type GraphRow = {
9 hash: string
10 short: string
11 subject: string
12 author: string
13 time: number
14 refs: Ref[]
15 lane: number
16 color: number
17 isMerge: boolean
18 segs: Seg[]
19}
20
21/** A local branch against its upstream: `git for-each-ref %(upstream:track)`. */
22export type Track = { upstream: string; ahead: number; behind: number; isGone: boolean }
23
24export type WorkFile = { path: string; code: string }
25
26/** The working tree: `git status --porcelain=v2 --branch`. */
27export type WorkTree = {
28 branch: string
29 ahead: number
30 behind: number
31 staged: number
32 modified: number
33 untracked: number
34 conflicted: number
35 files: WorkFile[]
36}
37
38export type Stash = { hash: string; ref: string; subject: string; time: number }
39
40export type Checks = 'pass' | 'fail' | 'pending' | 'none'
41
42export type PullRequest = {
43 number: number
44 state: 'OPEN' | 'MERGED' | 'CLOSED'
45 isDraft: boolean
46 checks: Checks
47 url: string
48}
49
50export type GitHub = {
51 /** Pull requests by head branch name, the newest first. */
52 prs: Record<string, PullRequest>
53 /** The newest workflow run's outcome, by commit hash. */
54 runs: Record<string, Checks>
55 fetchedAt: number
56 error?: string
57}
58
59/** How the log is narrowed on the git side: a message/author text, a path, or a commit. */
60export type Search = { q: string; mode: 'text' | 'path' | 'hash' }
61
62export type Graph = {
63 repo: string
64 total: number
65 rows: GraphRow[]
66 width: number
67 head: string | null
68 tracks: Record<string, Track>
69 branches: string[]
70 tags: string[]
71 work: WorkTree | null
72 stashes: Stash[]
73 error?: string
74}
75
76export type FileStat = { path: string; added: number; deleted: number }
77
78export type Detail = {
79 kind: 'commit' | 'compare' | 'worktree'
80 hash: string
81 short: string
82 /** The compare's other end, `base..hash`. */
83 base?: string
84 author: string
85 email: string
86 time: number
87 subject: string
88 body: string
89 parents: string[]
90 files: FileStat[]
91}
92
93export type FileDiff = { path: string; hunks: string }
94
95/** `all`, `current`, `local`, or `branch:<name>`. */
96export type Scope = string
97
98/** Which name a git action in the details card is asking for. */
99export type Naming = 'branch' | 'tag' | null
100
101declare module 'claude-code' {
102 interface PluginState {
103 'git-graph': {
104 graph: Graph | null
105 selected: string | null
106 detail: Detail | null
107 diff: FileDiff[] | null
108 limit: number
109 scope: Scope
110 query: string
111 search: Search | null
112 compareBase: string | null
113 claude: string[]
114 github: GitHub | null
115 isStashOpen: boolean
116 naming: Naming
117 offset: number
118 isHelpOpen: boolean
119 isMoreOpen: boolean
120 }
121 }
122}
123