SLOPSHOPPER

git-graph

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

newpaneguardcommandtoaststatus
v0.3.2no licenseupdated 2026-10-09DMKeyy/git-graph
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-graph
│ ┃ Git graph ✕ › fix the failing auth test and add an audit log call │ ┃ Git graph · GitHub offline ⟳ ? ▣ client mod │ ┃ All branches ▾ Jump to… ▾ ⏺ Read(src/auth.ts) │ ┃ Filter commits… (Enter searches all history, ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ No commit matches. ⏺ Bash(bun test) │ ┃ Try another branch filter. ⎿ 3 pass, 1 fail │ ┃ │ ┃ j: next k: prev h: HEAD f: search r: refresh ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /git-graph │ ⎿ git-graph: Git graph opened: 0 of 0 commits. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ git-graph: ⎇

Draws

Pane · Git graph
Git graph · GitHub offline ⟳ ? ▣ client module ./grip.tsx All branches ▾ Jump to… ▾ Filter commits… (Enter searches all history, path:… for a fi No commit matches. Try another branch filter. j: next k: prev h: HEAD f: search r: refresh
README

git-graph

A VS Code style git graph in a Claude Code pane: /git-graph opens it, /git-graph close closes it.

  • Branch lanes, merge curves, branch/remote/tag pills with ahead/behind counts
  • Uncommitted changes row and stashes above the history
  • Click a commit for its full message, details, diff and actions
  • Search: type to filter loaded commits, Enter searches all history (text, hash, or path:…)
  • Branch filter, jump to HEAD/branch/tag, infinite scroll
  • Commit details: files (click one for its history), diff, compare two commits
  • GitHub (through gh, read-only): PR number, state and checks on branches, CI result on commits
  • Commits Claude makes in a session are marked "✻ Claude"
  • Claude buttons fill the prompt box (never send): explain, review, summarize since, release notes, commit message
  • Git actions (checkout, new branch, tag, cherry-pick, revert, apply stash): each asks before it runs
  • Keys while the pane is focused: j/k next/prev, d diff, c compare, h HEAD, f search, r refresh
  • Drag the ⇔/⇕ grip in the header to resize; the size is remembered

Everything else is read-only: git runs with --no-optional-locks.

Install

/plugin marketplace add DMKeyy/git-graph
/plugin install git-graph@git-graph

Develop

claude plugin validate .
claude plugin test .
Source 4 files
hooks/register.tsx 1154 lines
1import { 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}
1154
hooks/layout.ts 480 lines
1import 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}
480
hooks/grip.tsx 53 lines
1import 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
53
types/index.d.ts 123 lines
1export 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