A pane that draws the commit graph of a local git repo, and a Portfolio pane with one activity card per local project

Two panes inside the Claude desktop app: a Git graph of the repo you're working in, and a Portfolio with one activity card per local project. Two buttons above the prompt open them, so you can see branches, worktrees and unpushed work without leaving the conversation.
<img src="docs/git-graph.svg" alt="The Git graph pane drawing the history of a real repository, with branch lanes and merge commits" width="860">
<sub>Drawn by the mod's own <code>renderSvg()</code> from the history of <a href="https://github.com/Abdulhamid-Baghdadi/double-scara-robot-ros">double-scara-robot-ros</a>. It follows your light or dark theme.</sub>
⎇ Git graph (/git-graph [repo path])
▦ Portfolio (/portfolio)
git through the mod API. Nothing about your repos leaves the machine unless you press Push.commit, push, fetch, checkout or any other command that writes.-c core.fsmonitor=false -c diff.external= -c core.pager=cat, so a cloned repo's own config can't make the pane run programs.GIT_TERMINAL_PROMPT=0. A push that needs credentials fails with a message instead of hanging.~/.claude/mods/git-graph: ``bash git clone https://github.com/Abdulhamid-Baghdadi/claude-git-graph.git ~/.claude/mods/git-graph ``CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json: ``json { "env": { "CLAUDE_CODE_PLUGIN_DIRS": "C:\\Users\\you\\.claude\\mods\\git-graph" } } ``| File | Role |
|---|---|
hooks/register.tsx | Everything that talks to the engine ($): commands, panes, the buttons above the prompt, git calls, refresh timers |
hooks/graph.ts | Pure helpers: parse git log, assign lanes and edges, render the graph as SVG, read git status lines |
hooks/portfolio.ts | Pure helpers: merge the project list, parse branch/worktree/log output, decide each card's status |
types/index.d.ts | Shared types (Snapshot, Commit, ProjectFacts, …) |
tests/ | 23 tests against a mocked engine and fake git output; no real repo is touched |
A few details:
claude plugin validate .
claude plugin test .
npx -p typescript tsc -p .
All code that calls the engine ($) lives in hooks/register.tsx, because the engine follows $ only within one file.
MIT © Abdulhamid Baghdadi
hooks/register.tsx 743 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GitAction, ProjectCard, ProjectFacts, ProjectStatus, Snapshot } from '../types'
5import { LOG_FORMAT, fileChange, parseLog, renderSvg, shortWhen, withWorkingRow } from './graph'
6import type { FileChange } from './graph'
7import {
8 DEFAULT_PROJECTS,
9 LAST_FORMAT,
10 age,
11 branchActivity,
12 errorStatus,
13 factLines,
14 lastCommit,
15 linkedWorktrees,
16 pathKey,
17 projectList,
18 sortCards,
19 statusOf,
20} from './portfolio'
21
22const PANE = 'git-graph'
23// Commits loaded at first, and how many more each "Show more" adds, up to MAX_LIMIT.
24const LIMIT = 150
25const MAX_LIMIT = 3000
26// Rows per Svg: a long graph is a stack of them, each under SVG_MAX.
27const CHUNK = 50
28// Uncommitted files listed in the changes box.
29const MAX_FILES = 200
30const REFRESH_MS = 30_000
31const SVG_MAX = 131_072
32
33const repo = atom({ plugin: 'git-graph', key: 'repo' } as const, '')
34const repos = atom({ plugin: 'git-graph', key: 'repos' } as const, [] as string[])
35const snapshot = atom({ plugin: 'git-graph', key: 'snapshot' } as const, null as Snapshot | null)
36const error = atom({ plugin: 'git-graph', key: 'error' } as const, null as { repo: string; message: string } | null)
37const isPicking = atom({ plugin: 'git-graph', key: 'isPicking' } as const, false)
38const limit = atom({ plugin: 'git-graph', key: 'limit' } as const, LIMIT)
39const action = atom({ plugin: 'git-graph', key: 'action' } as const, null as GitAction | null)
40
41// A repo's own config must not run programs when the pane reads it: no fsmonitor hook, no external diff or pager.
42// Not covered: clean/smudge filters a repo's own config defines; only open repos you got by cloning or made yourself.
43const SAFE = ['-c', 'core.fsmonitor=false', '-c', 'diff.external=', '-c', 'core.pager=cat', '-c', 'log.showSignature=false']
44
45const basename = (path: string) => path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || path
46const lines = (text: string) => text.split(/\r?\n/).filter(line => line.trim().length > 0)
47
48async function git($: EngineInterface, cwd: string, args: string[]) {
49 return $.process.run(['git', ...SAFE, ...args], { cwd, timeoutMs: 15_000 })
50}
51
52async function toplevel($: EngineInterface, path: string): Promise<string> {
53 const top = await git($, path, ['rev-parse', '--show-toplevel'])
54 if (top.exitCode !== 0) throw new Error(top.stderr.trim() || `${path} is not a git repository`)
55 return top.stdout.trim()
56}
57
58async function load($: EngineInterface, path: string, count: number): Promise<Snapshot> {
59 const root = await toplevel($, path)
60 const [log, status, branch, head, counts, worktrees, heads] = await Promise.all([
61 git($, root, ['log', '--branches', '--remotes', '--tags', 'HEAD', '--date-order', '-n', String(count + 1), `--format=${LOG_FORMAT}`]),
62 git($, root, ['status', '--porcelain']),
63 git($, root, ['rev-parse', '--abbrev-ref', 'HEAD']),
64 git($, root, ['rev-parse', 'HEAD']),
65 git($, root, ['rev-list', '--left-right', '--count', '@{u}...HEAD']),
66 git($, root, ['worktree', 'list', '--porcelain']),
67 git($, root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
68 ])
69 const commits = log.exitCode === 0 ? parseLog(log.stdout) : []
70 const [behind, ahead] = counts.exitCode === 0 ? counts.stdout.trim().split(/\s+/).map(Number) : []
71 const worktreeBranches = worktrees.stdout
72 .split(/\r?\n\r?\n/)
73 .slice(1)
74 .flatMap(block => lines(block).filter(line => line.startsWith('branch refs/heads/')))
75 .map(line => line.slice('branch refs/heads/'.length))
76
77 return {
78 repo: root,
79 branch: branch.exitCode === 0 ? branch.stdout.trim() : '(no commits yet)',
80 head: head.exitCode === 0 ? head.stdout.trim() : '',
81 commits: commits.slice(0, count),
82 dirty: lines(status.stdout).length,
83 files: lines(status.stdout).slice(0, MAX_FILES),
84 ahead: ahead ?? null,
85 behind: behind ?? null,
86 branches: lines(heads.stdout).map(line => line.trim()),
87 worktreeBranches,
88 isTruncated: commits.length > count,
89 loadedAt: await $.clock.now(),
90 }
91}
92
93const sameGraph = (a: Snapshot | null, b: Snapshot) =>
94 a !== null && JSON.stringify({ ...a, loadedAt: 0 }) === JSON.stringify({ ...b, loadedAt: 0 })
95
96let isLoading = false
97
98async function remember($: EngineInterface, path: string) {
99 const list = await update($, repos, list => [path, ...list.filter(other => other !== path)].slice(0, 12))
100 await $.store.set('repos', list)
101}
102
103// Draws `path` (or the current repo) and makes it the current one when it loads.
104async function refresh($: EngineInterface, path?: string): Promise<string | undefined> {
105 const target = path ?? (await read($, repo))
106 if (!target || (isLoading && path === undefined)) return
107 isLoading = true
108 try {
109 const next = await load($, target, await read($, limit))
110 if (path !== undefined || (await read($, repo)) !== next.repo) {
111 await update($, repo, () => next.repo)
112 await remember($, next.repo)
113 }
114 await update($, snapshot, current => (sameGraph(current, next) && current?.repo === next.repo ? current : next))
115 await update($, error, () => null)
116 return next.repo
117 } catch (err) {
118 const message = err instanceof Error ? err.message : String(err)
119 await update($, error, () => ({ repo: target, message }))
120 return undefined
121 } finally {
122 isLoading = false
123 }
124}
125
126const open = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Git graph' })
127
128// The pane's own write commands, run only when the person presses Commit or Push.
129// No terminal prompt: a push that needs credentials fails instead of hanging.
130async function gitWrite($: EngineInterface, cwd: string, args: string[], timeoutMs: number) {
131 return $.process.run(['git', ...SAFE, ...args], { cwd, timeoutMs, env: { GIT_TERMINAL_PROMPT: '0' } })
132}
133
134const lastLine = (text: string) => lines(text).pop()?.trim() ?? ''
135
136let isActing = false
137
138const CHANGE_COLOR: Record<FileChange['kind'], string> = { new: 'success', deleted: 'error', renamed: 'suggestion', modified: 'warning' }
139
140async function act($: EngineInterface, busy: string, work: (root: string) => Promise<GitAction>) {
141 const snap = await read($, snapshot)
142 if (!snap || isActing) return
143 isActing = true
144 await update($, action, () => ({ kind: 'busy', text: busy }))
145 try {
146 const result = await work(snap.repo)
147 await update($, action, () => result)
148 } catch (err) {
149 await update($, action, () => ({ kind: 'error', text: err instanceof Error ? err.message : String(err) }))
150 } finally {
151 isActing = false
152 await refresh($)
153 }
154}
155
156// Stages everything (new, changed and deleted files) and commits it with `message`.
157function commitAll($: EngineInterface, message: string) {
158 const text = message.trim()
159 if (!text) return update($, action, () => ({ kind: 'error', text: 'Write a commit message first.' }))
160 return act($, 'Committing…', async root => {
161 const add = await gitWrite($, root, ['add', '-A'], 60_000)
162 if (add.exitCode !== 0) return { kind: 'error', text: lastLine(add.stderr) || 'git add failed' }
163 const commit = await gitWrite($, root, ['commit', '-m', text], 60_000)
164 if (commit.exitCode !== 0) return { kind: 'error', text: lastLine(commit.stderr || commit.stdout) || 'git commit failed' }
165 return { kind: 'ok', text: lines(commit.stdout)[0]?.trim() || 'Committed.' }
166 })
167}
168
169// Pushes the checked-out branch; one with no upstream is published to the first remote.
170function push($: EngineInterface) {
171 return act($, 'Pushing…', async root => {
172 const snap = await read($, snapshot)
173 let args = ['push']
174 if (snap && snap.ahead === null) {
175 const remotes = await git($, root, ['remote'])
176 const remote = lines(remotes.stdout)[0]?.trim()
177 if (!remote) return { kind: 'error', text: 'This repo has no remote to push to.' }
178 args = ['push', '-u', remote, snap.branch]
179 }
180 const out = await gitWrite($, root, args, 120_000)
181 if (out.exitCode !== 0) return { kind: 'error', text: lastLine(out.stderr) || 'git push failed' }
182 return { kind: 'ok', text: lastLine(out.stderr) || 'Pushed.' }
183 })
184}
185
186async function showMore($: EngineInterface) {
187 await update($, limit, count => Math.min(MAX_LIMIT, count + LIMIT))
188 await refresh($)
189}
190
191// Switches the git graph pane to `path` and brings it up; false when it isn't a repo.
192async function showInGraph($: EngineInterface, path: string): Promise<boolean> {
193 const found = await refresh($, path)
194 if (!found) return false
195 await update($, isPicking, () => false)
196 await open($)
197 return true
198}
199
200// Portfolio: one card per local project.
201
202const PORTFOLIO_PANE = 'portfolio'
203const PORTFOLIO_REFRESH_MS = 60_000
204// Repos read at once; each runs about ten git commands.
205const PARALLEL = 3
206
207const portfolio = atom({ plugin: 'git-graph', key: 'portfolio' } as const, [] as ProjectCard[])
208const portfolioAt = atom({ plugin: 'git-graph', key: 'portfolioAt' } as const, 0)
209const isPortfolioLoading = atom({ plugin: 'git-graph', key: 'isPortfolioLoading' } as const, false)
210const isPortfolioEditing = atom({ plugin: 'git-graph', key: 'isPortfolioEditing' } as const, false)
211
212const unquote = (path: string) => path.trim().replace(/^["']|["']$/g, '')
213const strings = (value: unknown) => (Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [])
214
215// Read-only git only: `--no-optional-locks` keeps `status` from refreshing the index.
216async function readOnlyGit($: EngineInterface, cwd: string, args: string[]) {
217 return $.process.run(['git', ...SAFE, '--no-optional-locks', ...args], { cwd, timeoutMs: 15_000 })
218}
219
220async function readFacts($: EngineInterface, path: string, nowSec: number): Promise<ProjectFacts> {
221 const top = await readOnlyGit($, path, ['rev-parse', '--show-toplevel'])
222 if (top.exitCode !== 0) throw new Error(top.stderr.trim().split(/\r?\n/)[0] || 'Not a git repository')
223 const root = top.stdout.trim()
224 const [branch, recent, heads, worktrees, status, remotes, unpushed, upstream, last] = await Promise.all([
225 readOnlyGit($, root, ['rev-parse', '--abbrev-ref', 'HEAD']),
226 readOnlyGit($, root, ['rev-list', '--count', '--branches', `--since=@${nowSec - 86_400}`]),
227 readOnlyGit($, root, ['for-each-ref', '--format=%(committerdate:unix)', 'refs/heads']),
228 readOnlyGit($, root, ['worktree', 'list', '--porcelain']),
229 readOnlyGit($, root, ['status', '--porcelain']),
230 readOnlyGit($, root, ['remote']),
231 readOnlyGit($, root, ['rev-list', '--count', '--branches', '--not', '--remotes']),
232 readOnlyGit($, root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']),
233 readOnlyGit($, root, ['log', '-1', '--branches', `--format=${LAST_FORMAT}`]),
234 ])
235 const hasRemote = remotes.exitCode === 0 && remotes.stdout.trim().length > 0
236 return {
237 root,
238 branch: branch.exitCode === 0 ? branch.stdout.trim() : '(no commits yet)',
239 commits24h: recent.exitCode === 0 ? Number(recent.stdout.trim()) || 0 : 0,
240 ...branchActivity(heads.stdout, nowSec),
241 worktrees: worktrees.exitCode === 0 ? linkedWorktrees(worktrees.stdout) : 0,
242 dirty: status.stdout.split(/\r?\n/).filter(line => line.trim()).length,
243 hasRemote,
244 unpushed: hasRemote && unpushed.exitCode === 0 ? Number(unpushed.stdout.trim()) || 0 : null,
245 hasUpstream: upstream.exitCode === 0,
246 ...(last.exitCode === 0 ? lastCommit(last.stdout) : { lastCommitAt: null, lastSubject: '' }),
247 }
248}
249
250async function listed($: EngineInterface): Promise<{ paths: string[]; hidden: string[] }> {
251 const [recent, added, hidden] = await Promise.all([
252 $.store.get('repos'),
253 $.store.get('portfolio.added'),
254 $.store.get('portfolio.hidden'),
255 ])
256 return { paths: projectList(DEFAULT_PROJECTS, strings(recent), strings(added), strings(hidden)), hidden: strings(hidden) }
257}
258
259let isPortfolioRunning = false
260
261async function reloadPortfolio($: EngineInterface) {
262 if (isPortfolioRunning) return
263 isPortfolioRunning = true
264 await update($, isPortfolioLoading, () => true)
265 try {
266 // A deleted folder drops off the list; it comes back if the folder does.
267 const listedPaths = (await listed($)).paths
268 const present = await Promise.all(listedPaths.map(path => $.fs.exists(path).catch(() => true)))
269 const paths = listedPaths.filter((_, index) => present[index])
270 const nowSec = Math.floor((await $.clock.now()) / 1000)
271 const out: ProjectCard[] = new Array(paths.length)
272 let cursor = 0
273 const worker = async () => {
274 while (cursor < paths.length) {
275 const index = cursor++
276 const path = paths[index] ?? ''
277 try {
278 const facts = await readFacts($, path, nowSec)
279 out[index] = { path, facts, error: null, status: statusOf(facts, nowSec) }
280 } catch (err) {
281 const message = err instanceof Error ? err.message : String(err)
282 out[index] = { path, facts: null, error: message, status: errorStatus(message) }
283 }
284 }
285 }
286 await Promise.all(Array.from({ length: PARALLEL }, worker))
287 // Two listed folders inside one repo show as one card.
288 const seen = new Set<string>()
289 const unique = out.filter(card => {
290 const key = pathKey(card.facts?.root ?? card.path)
291 if (seen.has(key)) return false
292 seen.add(key)
293 return true
294 })
295 await update($, portfolio, () => sortCards(unique))
296 await update($, portfolioAt, () => nowSec)
297 } finally {
298 await update($, isPortfolioLoading, () => false)
299 isPortfolioRunning = false
300 }
301}
302
303async function addProject($: EngineInterface, raw: string) {
304 const path = unquote(raw)
305 if (!path) return
306 const key = pathKey(path)
307 const added = strings(await $.store.get('portfolio.added')).filter(other => pathKey(other) !== key)
308 const hidden = strings(await $.store.get('portfolio.hidden')).filter(other => pathKey(other) !== key)
309 await $.store.set('portfolio.added', [...added, path])
310 await $.store.set('portfolio.hidden', hidden)
311 await reloadPortfolio($)
312}
313
314async function removeProject($: EngineInterface, card: ProjectCard) {
315 const keys = new Set([pathKey(card.path), card.facts ? pathKey(card.facts.root) : ''])
316 const added = strings(await $.store.get('portfolio.added')).filter(other => !keys.has(pathKey(other)))
317 const hidden = strings(await $.store.get('portfolio.hidden')).filter(other => !keys.has(pathKey(other)))
318 await $.store.set('portfolio.added', added)
319 await $.store.set('portfolio.hidden', [...hidden, card.path])
320 await update($, portfolio, list => list.filter(other => other.path !== card.path))
321}
322
323async function restoreProject($: EngineInterface, path: string) {
324 const hidden = strings(await $.store.get('portfolio.hidden')).filter(other => pathKey(other) !== pathKey(path))
325 await $.store.set('portfolio.hidden', hidden)
326 await reloadPortfolio($)
327}
328
329const isShown = async ($: EngineInterface) => (await $.ui.panes()).some(pane => pane.id === PORTFOLIO_PANE && pane.isShown)
330
331const CHIP: Record<ProjectStatus['kind'], string> = {
332 attention: 'warning',
333 active: 'success',
334 idle: 'inactive',
335 error: 'error',
336}
337
338// From the plugin's `session.start`: the command, and a refresh while the pane is shown.
339async function startPortfolio($: EngineInterface) {
340 await $.command.register({
341 name: 'portfolio',
342 description: 'Show one card per local project: commits today, branches, uncommitted and unpushed work',
343 })
344 $.clock.every(PORTFOLIO_REFRESH_MS, () => {
345 void isShown($).then(shown => {
346 if (shown) void reloadPortfolio($)
347 })
348 })
349}
350
351// From the plugin's `turn.complete`.
352async function portfolioAfterTurn($: EngineInterface) {
353 const nowSec = Math.floor((await $.clock.now()) / 1000)
354 if (nowSec - (await read($, portfolioAt)) > 30 && (await isShown($))) void reloadPortfolio($)
355}
356
357export const register: Register = on => {
358 // Two buttons above the prompt, so neither pane needs a typed command.
359 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
360 if (e.props.hasSurvey) return next(e)
361 const { Box, Button } = $.ui.resolve(e)
362 return (
363 <Box flexDirection="row" gap={1}>
364 <Button
365 key="show-graph"
366 dimColor
367 label="⎇ Git graph"
368 onPress={() => {
369 void open($)
370 void refresh($)
371 }}
372 />
373 <Button
374 key="show-portfolio"
375 dimColor
376 label="▦ Portfolio"
377 onPress={() => {
378 void $.ui.open({ id: PORTFOLIO_PANE, title: 'Portfolio' })
379 void reloadPortfolio($)
380 }}
381 />
382 </Box>
383 )
384 })
385
386 on('session.start', async ($, e, next) => {
387 await $.command.register({
388 name: 'git-graph',
389 description: 'Show the git graph pane; pass a repo path to switch to it',
390 argumentHint: '[repo path]',
391 })
392 const stored = await $.store.get('repos')
393 if (Array.isArray(stored)) await update($, repos, () => stored.filter(item => typeof item === 'string'))
394
395 void (async () => {
396 const cwd = await $.session.cwd()
397 const found = await refresh($, cwd)
398 if (found) void open($)
399 else {
400 await update($, error, () => null)
401 const [last] = await read($, repos)
402 if (last && !(await read($, repo))) await refresh($, last)
403 }
404 })()
405 $.clock.every(REFRESH_MS, () => void refresh($))
406 await startPortfolio($)
407
408 return next(e)
409 })
410
411 on('turn.complete', async ($, e, next) => {
412 const result = await next(e)
413 void refresh($)
414 void portfolioAfterTurn($)
415 return result
416 })
417
418 on('command.run', { command: 'git-graph' }, async ($, e) => {
419 const path = e.args.trim().replace(/^["']|["']$/g, '')
420 await open($)
421 if (!path) {
422 void refresh($)
423 return { text: 'Git graph pane opened.' }
424 }
425 const found = await refresh($, path)
426 if (found) await update($, isPicking, () => false)
427 return { text: found ? `Git graph now shows ${found}.` : `Couldn't read a git repo at ${path}.` }
428 })
429
430 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
431 const table = $.ui.resolve(e)
432 const { Box, Text, Button } = table
433 const current = await read($, repo)
434 const snap = await read($, snapshot)
435 const problem = await read($, error)
436 const picking = (await read($, isPicking)) || !current
437 const known = await read($, repos)
438 const shown = snap && snap.repo === current ? snap : null
439
440 const choose = async (path: string) => {
441 const found = await refresh($, path)
442 if (found) await update($, isPicking, () => false)
443 }
444
445 const picker = (
446 <Box flexDirection="column" gap={1} marginBottom={1}>
447 {known.length > 0 && 'Select' in table && (
448 <table.Select
449 key="recent"
450 label="Recent repos"
451 value={known.includes(current) ? current : undefined}
452 options={known.map(path => ({ value: path, label: `${basename(path)} ${path}` }))}
453 onSelect={path => void choose(path)}
454 />
455 )}
456 {'Input' in table && (
457 <table.Input
458 key="path"
459 label="Repo folder"
460 placeholder="C:\Users\you\Desktop\my-project"
461 submitLabel="show"
462 onSubmit={path => void choose(path.trim().replace(/^["']|["']$/g, ''))}
463 />
464 )}
465 <Box flexDirection="row" gap={1}>
466 <Button key="cwd" label="Use this session's folder" onPress={() => void $.session.cwd().then(choose)} />
467 {current && <Button key="cancel" label="Cancel" onPress={() => void update($, isPicking, () => false)} />}
468 </Box>
469 </Box>
470 )
471
472 const last = await read($, action)
473 const canPush = !!shown && !!shown.head && (shown.ahead === null || shown.ahead > 0)
474 const changes = shown && (shown.dirty > 0 || canPush || last) && (
475 <Box flexDirection="column" borderStyle="round" borderColor={shown.dirty > 0 ? 'warning' : 'inactive'} paddingX={1} marginBottom={1}>
476 {shown.dirty > 0 ? (
477 <Box flexDirection="column">
478 <Text bold>
479 {shown.dirty === 1 ? '1 uncommitted file' : `${shown.dirty} uncommitted files`}
480 </Text>
481 {shown.files.slice(0, 12).map(line => {
482 const change = fileChange(line)
483 return (
484 <Text wrap="truncate-middle">
485 <Text color={CHANGE_COLOR[change.kind]}>{change.kind}</Text> {change.path}
486 </Text>
487 )
488 })}
489 {shown.dirty > 12 && <Text dimColor>…and {shown.dirty - 12} more</Text>}
490 {'Input' in table && (
491 <table.Input
492 key="commit-message"
493 label="Commit message"
494 placeholder="What did you change?"
495 submitLabel="Commit all"
496 onSubmit={message => void commitAll($, message)}
497 />
498 )}
499 </Box>
500 ) : (
501 <Text dimColor>No uncommitted changes.</Text>
502 )}
503 {canPush && (
504 <Box flexDirection="row" gap={1} marginTop={1}>
505 <Button
506 key="push"
507 variant="primary"
508 label={
509 shown.ahead === null
510 ? `Publish ${shown.branch}`
511 : `Push ${shown.ahead === 1 ? '1 commit' : `${shown.ahead} commits`}`
512 }
513 onPress={() => void push($)}
514 />
515 </Box>
516 )}
517 {last && (
518 <Text color={last.kind === 'error' ? 'error' : last.kind === 'ok' ? 'success' : undefined} dimColor={last.kind === 'busy'} wrap="wrap">
519 {last.text}
520 </Text>
521 )}
522 </Box>
523 )
524
525 const header = shown && (
526 <Box flexDirection="column" marginBottom={1}>
527 <Box flexDirection="row" gap={1} alignItems="center">
528 <Text bold>{basename(shown.repo)}</Text>
529 <Text dimColor>on {shown.branch}</Text>
530 <Box flexGrow={1} />
531 <Button key="refresh" label="Refresh" onPress={() => void refresh($)} />
532 <Button key="switch" label="Switch repo" onPress={() => void update($, isPicking, () => true)} />
533 </Box>
534 <Text dimColor wrap="wrap">
535 {[
536 `${shown.branches.length} ${shown.branches.length === 1 ? 'branch' : 'branches'}`,
537 shown.worktreeBranches.length > 0 && `${shown.worktreeBranches.length} worktrees (⎇)`,
538 shown.dirty > 0 && `${shown.dirty} uncommitted`,
539 shown.ahead !== null && shown.ahead > 0 && `${shown.ahead} not pushed`,
540 shown.behind !== null && shown.behind > 0 && `${shown.behind} to pull`,
541 shown.ahead === null && shown.head && 'no upstream',
542 ]
543 .filter(Boolean)
544 .join(' · ')}
545 </Text>
546 </Box>
547 )
548
549 const columns = e.props.bodyColumns
550 let graph
551 const shownCount = shown?.commits.length ?? 0
552 if (!shown) graph = !picking && <Text dimColor>Loading…</Text>
553 else if (shown.commits.length === 0) graph = <Text dimColor>No commits yet.</Text>
554 else if ('Svg' in table) {
555 const width = Math.round(Math.min(1100, Math.max(360, columns * 7.5)))
556 // The engine refuses an Svg past SVG_MAX characters, and with it the whole pane: draw the graph
557 // as a stack of row ranges, halving any range that is still too big.
558 const rows = withWorkingRow(shown).length
559 const pieces: { source: string; height: number; from: number }[] = []
560 const draw = (from: number, to: number) => {
561 const piece = renderSvg(shown, width, from, to)
562 if (piece.source.length > SVG_MAX && to - from > 1) {
563 const mid = from + Math.floor((to - from) / 2)
564 draw(from, mid)
565 draw(mid, to)
566 } else if (piece.source.length <= SVG_MAX) pieces.push({ ...piece, from })
567 }
568 for (let from = 0; from < rows; from += CHUNK) draw(from, Math.min(rows, from + CHUNK))
569 graph = (
570 <Box flexDirection="column">
571 {pieces.map(piece => (
572 <table.Svg
573 source={piece.source}
574 alt={`Commit graph of ${basename(shown.repo)}, from row ${piece.from + 1}`}
575 width={width}
576 height={piece.height}
577 isInteractive
578 />
579 ))}
580 </Box>
581 )
582 } else {
583 const room = Math.max(1, (e.viewport?.rows ?? 30) - 6)
584 graph = (
585 <Box flexDirection="column">
586 {withWorkingRow(shown)
587 .slice(0, room)
588 .map(commit => (
589 <Text wrap="truncate-end">
590 {commit.isWorking ? '◌' : '●'} {commit.subject} <Text dimColor>{shortWhen(commit.when)}</Text>
591 </Text>
592 ))}
593 </Box>
594 )
595 }
596
597 return (
598 <Box flexDirection="column">
599 {header}
600 {problem && (
601 <Text color="error" wrap="wrap">
602 {problem.message}
603 </Text>
604 )}
605 {picking && picker}
606 {changes}
607 {graph}
608 {shown && (
609 <Box flexDirection="row" gap={1} alignItems="center" marginTop={1}>
610 <Text dimColor>
611 {shown.isTruncated ? `Showing the latest ${shownCount} commits.` : `All ${shownCount} commits.`} Hover a commit for details.
612 </Text>
613 {shown.isTruncated && shownCount < MAX_LIMIT && (
614 <Button key="more" label={`Show ${LIMIT} more`} onPress={() => void showMore($)} />
615 )}
616 </Box>
617 )}
618 </Box>
619 )
620 })
621
622 on('command.run', { command: 'portfolio' }, async $ => {
623 await $.ui.open({ id: PORTFOLIO_PANE, title: 'Portfolio' })
624 void reloadPortfolio($)
625 return { text: 'Portfolio pane opened.' }
626 })
627
628 on('ui.render', { component: 'Pane', requestId: PORTFOLIO_PANE }, async ($, e) => {
629 const table = $.ui.resolve(e)
630 const { Box, Text, Button } = table
631 const list = await read($, portfolio)
632 const at = await read($, portfolioAt)
633 const loading = await read($, isPortfolioLoading)
634 const editing = await read($, isPortfolioEditing)
635 const nowSec = Math.floor((await $.clock.now()) / 1000)
636 const hidden = editing ? (await listed($)).hidden : []
637
638 const count = (kind: ProjectStatus['kind']) => list.filter(card => card.status.kind === kind).length
639 const summary = [
640 count('attention') > 0 && `${count('attention')} need attention`,
641 count('active') > 0 && `${count('active')} active`,
642 count('idle') > 0 && `${count('idle')} idle`,
643 count('error') > 0 && `${count('error')} unreadable`,
644 ]
645 .filter(Boolean)
646 .join(' · ')
647
648 const columns = Math.max(30, e.props.bodyColumns)
649 const perRow = Math.max(1, Math.floor(columns / 40))
650 const width = Math.floor((columns - (perRow - 1)) / perRow)
651
652 const open = async (card: ProjectCard) => {
653 const ok = await showInGraph($, card.facts?.root ?? card.path)
654 if (!ok) $.ui.toast(`Couldn't open ${basename(card.path)} in the git graph.`)
655 }
656
657 const card = (item: ProjectCard, index: number) => {
658 const color = CHIP[item.status.kind]
659 const name = basename(item.facts?.root ?? item.path)
660 return (
661 <Box
662 key={`card-${index}`}
663 flexDirection="column"
664 borderStyle="round"
665 borderColor={color}
666 paddingX={1}
667 width={width}
668 hover={{ borderColor: 'claude' }}
669 >
670 <Box flexDirection="row" gap={1}>
671 <Button key={`open-${index}`} plain label={name} onPress={() => void open(item)} />
672 <Box flexGrow={1} />
673 <Text color={color} bold>
674 {item.status.label}
675 </Text>
676 <Button key={`untrack-${index}`} plain dimColor label="✕" onPress={() => void removeProject($, item)} />
677 </Box>
678 {item.status.kind === 'attention' && (
679 <Text color={color} wrap="wrap">
680 {item.status.reasons.join(' · ')}
681 </Text>
682 )}
683 {item.facts ? (
684 factLines(item.facts, nowSec).map(line => (
685 <Text dimColor wrap="truncate-end">
686 {line}
687 </Text>
688 ))
689 ) : (
690 <Text dimColor wrap="wrap">
691 {item.error}
692 </Text>
693 )}
694 <Text dimColor wrap="truncate-middle">
695 {item.facts?.root ?? item.path}
696 </Text>
697 </Box>
698 )
699 }
700
701 return (
702 <Box flexDirection="column" gap={1}>
703 <Box flexDirection="row" gap={1} alignItems="center">
704 <Text bold>{list.length === 1 ? '1 project' : `${list.length} projects`}</Text>
705 <Text dimColor>
706 {loading ? 'reading…' : at > 0 ? `updated ${age(Math.max(0, nowSec - at))}` : ''}
707 </Text>
708 <Box flexGrow={1} />
709 <Button key="refresh" label="Refresh" onPress={() => void reloadPortfolio($)} />
710 <Button key="edit" label={editing ? 'Done' : 'Edit list'} onPress={() => void update($, isPortfolioEditing, value => !value)} />
711 </Box>
712 {summary && <Text>{summary}</Text>}
713 {editing && (
714 <Box flexDirection="column" gap={1}>
715 {'Input' in table && (
716 <table.Input
717 key="add"
718 label="Add a repo folder"
719 placeholder="C:\Users\you\Desktop\my-project"
720 submitLabel="add"
721 onSubmit={path => void addProject($, path)}
722 />
723 )}
724 {hidden.length > 0 && (
725 <Box flexDirection="row" flexWrap="wrap" gap={1}>
726 <Text dimColor>Removed:</Text>
727 {hidden.map((path, index) => (
728 <Button key={`restore-${index}`} dimColor label={`↺ ${basename(path)}`} onPress={() => void restoreProject($, path)} />
729 ))}
730 </Box>
731 )}
732 </Box>
733 )}
734 {list.length === 0 && <Text dimColor>{loading ? 'Reading your projects…' : 'No projects listed.'}</Text>}
735 <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>
736 {list.map(card)}
737 </Box>
738 <Text dimColor>Click a project's name to show it in the git graph · ✕ untracks it (restore under Edit list).</Text>
739 </Box>
740 )
741 })
742}
743hooks/graph.ts 249 lines1import type { Commit, Snapshot } from '../types'
2
3const FIELD = '\x1f'
4const RECORD = '\x1e'
5
6export const LOG_FORMAT = ['%H', '%P', '%D', '%an', '%cr', '%ci', '%s'].join('%x1f') + '%x1e'
7
8export function parseLog(stdout: string): Commit[] {
9 return stdout
10 .split(RECORD)
11 .map(record => record.replace(/^\s+/, ''))
12 .filter(record => record.length > 0)
13 .map(record => {
14 const [hash = '', parents = '', refs = '', author = '', when = '', date = '', ...subject] =
15 record.split(FIELD)
16 return {
17 hash,
18 parents: parents.split(' ').filter(Boolean),
19 refs: refs.split(', ').map(ref => ref.trim()).filter(Boolean),
20 author,
21 when,
22 date,
23 subject: subject.join(FIELD).trim(),
24 }
25 })
26}
27
28// Each commit gets a lane; each parent link is an edge that leaves its child's
29// lane, travels down `via`, and lands in the parent's lane at the parent's row.
30export type Edge = { fromRow: number; fromLane: number; via: number; toRow: number; toLane: number }
31export type Layout = { lanes: number[]; edges: Edge[]; width: number }
32
33export function layout(commits: readonly Commit[]): Layout {
34 const open: (string | null)[] = []
35 const lanes: number[] = []
36 const pending: { fromRow: number; fromLane: number; via: number; parent: string }[] = []
37 const rowOf = new Map(commits.map((commit, row) => [commit.hash, row]))
38 let width = 1
39
40 const claim = (hash: string) => {
41 const free = open.indexOf(null)
42 const lane = free === -1 ? open.length : free
43 open[lane] = hash
44 return lane
45 }
46
47 commits.forEach((commit, row) => {
48 let lane = open.indexOf(commit.hash)
49 if (lane === -1) lane = claim(commit.hash)
50 open.forEach((hash, other) => {
51 if (other !== lane && hash === commit.hash) open[other] = null
52 })
53 lanes[row] = lane
54
55 if (commit.parents.length === 0) open[lane] = null
56 commit.parents.forEach((parent, index) => {
57 let via = lane
58 if (index === 0) open[lane] = parent
59 else {
60 via = open.indexOf(parent)
61 if (via === -1) via = claim(parent)
62 }
63 pending.push({ fromRow: row, fromLane: lane, via, parent })
64 })
65 width = Math.max(width, open.length)
66 })
67
68 const edges = pending.map(({ fromRow, fromLane, via, parent }) => {
69 const toRow = rowOf.get(parent)
70 return toRow === undefined
71 ? { fromRow, fromLane, via, toRow: commits.length, toLane: via }
72 : { fromRow, fromLane, via, toRow, toLane: lanes[toRow] ?? via }
73 })
74
75 return { lanes, edges, width }
76}
77
78const COLORS = ['#7F77DD', '#1D9E75', '#D85A30', '#378ADD', '#D4537E', '#BA7517', '#639922', '#888780']
79const ROW = 24
80const LANE = 14
81const PAD = 12
82const CHAR = 6.6
83const TIP_MAX = 160
84
85const escape = (text: string) =>
86 text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
87
88const clip = (text: string, room: number) => {
89 const chars = Math.max(0, Math.floor(room / CHAR))
90 return text.length <= chars ? text : text.slice(0, Math.max(0, chars - 1)) + '…'
91}
92
93// "3 hours ago" -> "3h"; git's relative dates are English whatever the locale.
94export function shortWhen(when: string): string {
95 const match = /^(\d+) (second|minute|hour|day|week|month|year)s? ago/.exec(when)
96 if (!match) return when
97 const unit = { second: 's', minute: 'm', hour: 'h', day: 'd', week: 'w', month: 'mo', year: 'y' }[match[2] ?? '']
98 return `${match[1]}${unit ?? ''}`
99}
100
101type Pill = { text: string; kind: 'head' | 'local' | 'remote' | 'tag' | 'worktree' }
102
103export function pills(refs: readonly string[], branches: readonly string[], worktrees: readonly string[]): Pill[] {
104 const here = new Set(refs.map(ref => (ref.startsWith('HEAD -> ') ? ref.slice(8) : ref)))
105 const out: Pill[] = []
106 for (const ref of refs) {
107 if (ref === 'HEAD') out.push({ text: 'HEAD', kind: 'head' })
108 else if (ref.startsWith('HEAD -> ')) out.push({ text: ref.slice(8), kind: 'head' })
109 else if (ref.startsWith('tag: ')) out.push({ text: ref.slice(5), kind: 'tag' })
110 else if (branches.includes(ref)) out.push({ text: ref, kind: worktrees.includes(ref) ? 'worktree' : 'local' })
111 else {
112 // A remote branch sitting on the same commit as its local twin adds nothing.
113 const name = ref.slice(ref.indexOf('/') + 1)
114 if (name !== 'HEAD' && !here.has(name)) out.push({ text: ref, kind: 'remote' })
115 }
116 }
117 return out
118}
119
120export function withWorkingRow(snapshot: Snapshot): Commit[] {
121 if (snapshot.dirty === 0 || !snapshot.commits.some(commit => commit.hash === snapshot.head)) {
122 return snapshot.commits
123 }
124 const files = snapshot.dirty === 1 ? '1 file' : `${snapshot.dirty} files`
125 return [
126 {
127 hash: '',
128 parents: [snapshot.head],
129 refs: [],
130 author: '',
131 when: 'now',
132 date: '',
133 subject: `Uncommitted changes (${files})`,
134 isWorking: true,
135 },
136 ...snapshot.commits,
137 ]
138}
139
140// Rows `from` up to `to` of the whole graph (all of it by default): a long history is drawn as
141// several of these stacked, since the engine refuses one Svg past 131072 characters.
142export function renderSvg(snapshot: Snapshot, width: number, from = 0, to = Infinity): { source: string; height: number } {
143 const commits = withWorkingRow(snapshot)
144 const { lanes, edges, width: laneCount } = layout(commits)
145 const height = Math.max(1, commits.length) * ROW + 8
146 const top = from <= 0 ? 0 : 4 + from * ROW
147 const bottom = to >= commits.length ? height : 4 + to * ROW
148 const x = (lane: number) => PAD + lane * LANE
149 const y = (row: number) => 4 + row * ROW + ROW / 2
150 const color = (lane: number) => COLORS[lane % COLORS.length]
151 const textX = PAD + laneCount * LANE + 4
152 const parts: string[] = []
153
154 for (const edge of edges) {
155 const x0 = x(edge.fromLane)
156 const y0 = y(edge.fromRow)
157 const xv = x(edge.via)
158 const xt = x(edge.toLane)
159 const yt = edge.toRow >= commits.length ? height : y(edge.toRow)
160 if (yt < top || y0 > bottom) continue
161 const half = ROW / 2
162 let d = `M${x0},${y0}`
163 if (edge.toRow === edge.fromRow + 1 && edge.toRow < commits.length) {
164 d += x0 === xt ? ` L${xt},${yt}` : ` C${x0},${y0 + half} ${xt},${yt - half} ${xt},${yt}`
165 } else {
166 if (xv !== x0) d += ` C${x0},${y0 + half} ${xv},${y0 + half} ${xv},${y0 + ROW}`
167 d += ` L${xv},${yt - ROW}`
168 d += xt === xv ? ` L${xt},${yt}` : ` C${xv},${yt - half} ${xt},${yt - half} ${xt},${yt}`
169 }
170 const isWorking = commits[edge.fromRow]?.isWorking === true
171 parts.push(
172 `<path d="${d}" stroke="${color(edge.via)}" stroke-width="2" fill="none"${isWorking ? ' stroke-dasharray="3 3"' : ''}/>`,
173 )
174 }
175
176 commits.forEach((commit, row) => {
177 if (row < from || row >= to) return
178 const lane = lanes[row] ?? 0
179 const cy = y(row)
180 // Tooltips are most of the markup, and the engine refuses an Svg past 131072 characters: keep each short.
181 const short = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
182 const tip = [
183 commit.isWorking ? commit.subject : `${commit.hash.slice(0, 8)} ${short(commit.subject, TIP_MAX)}`,
184 commit.author && `${commit.author} · ${commit.date}`,
185 commit.refs.length > 0 && short(commit.refs.join(', '), TIP_MAX),
186 ]
187 .filter(Boolean)
188 .join('\n')
189 parts.push(`<g><title>${escape(tip)}</title>`)
190 parts.push(`<rect class="hit" x="0" y="${cy - ROW / 2}" width="${width}" height="${ROW}"/>`)
191 if (commit.isWorking) {
192 parts.push(`<circle cx="${x(lane)}" cy="${cy}" r="5" fill="none" stroke="${color(lane)}" stroke-width="2" stroke-dasharray="2 2"/>`)
193 } else if (commit.parents.length > 1) {
194 parts.push(`<circle cx="${x(lane)}" cy="${cy}" r="5" class="bg" stroke="${color(lane)}" stroke-width="2.5"/>`)
195 } else {
196 parts.push(`<circle cx="${x(lane)}" cy="${cy}" r="5" fill="${color(lane)}"/>`)
197 }
198
199 const when = shortWhen(commit.when)
200 const whenX = width - PAD
201 let cursor = textX
202 for (const pill of pills(commit.refs, snapshot.branches, snapshot.worktreeBranches)) {
203 const label = clip(pill.kind === 'worktree' ? `${pill.text} ⎇` : pill.text, 150)
204 const w = label.length * 6.1 + 10
205 if (cursor + w > whenX - 120) break
206 const stroke = pill.kind === 'remote' || pill.kind === 'tag' ? 'var(--muted)' : color(lane)
207 const fill = pill.kind === 'remote' || pill.kind === 'tag' ? 'none' : color(lane)
208 parts.push(
209 `<rect x="${cursor}" y="${cy - 8}" width="${w}" height="16" rx="4" fill="${fill}" fill-opacity="${fill === 'none' ? 0 : 0.18}" stroke="${stroke}" stroke-width="${pill.kind === 'head' ? 1.5 : 0.75}"/>`,
210 `<text x="${cursor + 5}" y="${cy + 4}" class="pill${pill.kind === 'head' ? ' strong' : ''}">${escape(label)}</text>`,
211 )
212 cursor += w + 4
213 }
214 const room = whenX - 34 - cursor
215 parts.push(
216 `<text x="${cursor}" y="${cy + 4}" class="${commit.isWorking ? 'msg working' : 'msg'}">${escape(clip(commit.subject, room))}</text>`,
217 `<text x="${whenX}" y="${cy + 4}" class="when" text-anchor="end">${escape(when)}</text>`,
218 `</g>`,
219 )
220 })
221
222 const style = [
223 'svg{--fg:#2c2c2a;--muted:#888780;--bg:#ffffff;--hover:rgba(0,0,0,0.05)}',
224 '@media (prefers-color-scheme:dark){svg{--fg:#e8e6df;--muted:#9c9a92;--bg:#262624;--hover:rgba(255,255,255,0.07)}}',
225 'text{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;font-size:12px;fill:var(--fg)}',
226 '.pill{font-size:11px}.strong{font-weight:600}.when{fill:var(--muted);font-size:11px}',
227 '.working{fill:var(--muted);font-style:italic}.bg{fill:var(--bg)}',
228 '.hit{fill:transparent}g:hover .hit{fill:var(--hover)}',
229 ].join('')
230
231 const h = bottom - top
232 const source =
233 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 ${top} ${width} ${h}">` +
234 `<style>${style}</style><rect class="bg" y="${top}" width="${width}" height="${h}"/>${parts.join('')}</svg>`
235 return { source, height: h }
236}
237
238export type FileChange = { kind: 'new' | 'deleted' | 'renamed' | 'modified'; path: string }
239
240// One `git status --porcelain` line ("XY path", or "R old -> new").
241export function fileChange(line: string): FileChange {
242 const code = line.slice(0, 2)
243 const path = line.slice(3)
244 if (code === '??' || code.includes('A')) return { kind: 'new', path }
245 if (code.includes('D')) return { kind: 'deleted', path }
246 if (code.includes('R')) return { kind: 'renamed', path }
247 return { kind: 'modified', path }
248}
249hooks/portfolio.ts 119 lines1import type { ProjectCard, ProjectFacts, ProjectStatus } from '../types'
2
3const DAY = 86_400
4
5// A project with this many uncommitted files needs attention even if nothing is unpushed.
6export const DIRTY_ATTENTION = 10
7// A branch with a commit this recent counts as active.
8export const ACTIVE_BRANCH_DAYS = 7
9
10// Projects shown before any are added or opened in the git graph; none by default.
11export const DEFAULT_PROJECTS: readonly string[] = []
12
13const lines = (text: string) => text.split(/\r?\n/).filter(line => line.trim().length > 0)
14const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
15
16// Windows paths compare without case, and git prints them with forward slashes.
17export const pathKey = (path: string) => path.trim().replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
18
19// The defaults, then the repos the git graph has shown, then ones added here; minus hidden ones.
20export function projectList(
21 defaults: readonly string[],
22 recent: readonly string[],
23 added: readonly string[],
24 hidden: readonly string[],
25): string[] {
26 const out = new Map<string, string>()
27 const gone = new Set(hidden.map(pathKey))
28 for (const path of [...defaults, ...recent, ...added]) {
29 const key = pathKey(path)
30 if (path.trim() && !gone.has(key) && !out.has(key)) out.set(key, path.trim())
31 }
32 return [...out.values()]
33}
34
35// `for-each-ref --format=%(committerdate:unix) refs/heads`
36export function branchActivity(stdout: string, nowSec: number): { branches: number; activeBranches: number } {
37 const dates = lines(stdout).map(line => Number(line.trim()))
38 return {
39 branches: dates.length,
40 activeBranches: dates.filter(at => Number.isFinite(at) && nowSec - at <= ACTIVE_BRANCH_DAYS * DAY).length,
41 }
42}
43
44// `worktree list --porcelain`: one block per worktree, the main checkout first.
45export function linkedWorktrees(stdout: string): number {
46 return Math.max(0, lines(stdout).filter(line => line.startsWith('worktree ')).length - 1)
47}
48
49export const LAST_FORMAT = '%ct%x1f%s'
50
51// `log -1 --branches --format=%ct%x1f%s`
52export function lastCommit(stdout: string): { lastCommitAt: number | null; lastSubject: string } {
53 const [at = '', ...subject] = stdout.trim().split('\x1f')
54 const time = Number(at)
55 return at && Number.isFinite(time)
56 ? { lastCommitAt: time, lastSubject: subject.join('\x1f').trim() }
57 : { lastCommitAt: null, lastSubject: '' }
58}
59
60export function age(seconds: number): string {
61 if (seconds < 60) return 'just now'
62 if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
63 if (seconds < DAY) return `${Math.floor(seconds / 3600)}h ago`
64 if (seconds < 60 * DAY) return `${Math.floor(seconds / DAY)}d ago`
65 return `${Math.floor(seconds / (30 * DAY))}mo ago`
66}
67
68export function statusOf(facts: ProjectFacts, nowSec: number): ProjectStatus {
69 const reasons: string[] = []
70 if (facts.unpushed !== null && facts.unpushed > 0) reasons.push(`${plural(facts.unpushed, 'commit')} not pushed`)
71 if (facts.dirty >= DIRTY_ATTENTION) reasons.push(`${plural(facts.dirty, 'uncommitted file')}`)
72 if (reasons.length > 0) return { kind: 'attention', label: 'Needs attention', reasons }
73
74 if (facts.commits24h > 0) {
75 return { kind: 'active', label: 'Active', reasons: [`${plural(facts.commits24h, 'commit')} in the last 24h`] }
76 }
77 if (facts.lastCommitAt === null) return { kind: 'idle', label: 'No commits yet', reasons: [] }
78 const days = Math.max(0, Math.floor((nowSec - facts.lastCommitAt) / DAY))
79 return { kind: 'idle', label: days === 0 ? 'Idle today' : `Idle ${plural(days, 'day')}`, reasons: [] }
80}
81
82export const errorStatus = (message: string): ProjectStatus => ({ kind: 'error', label: "Can't read", reasons: [message] })
83
84const RANK: Record<ProjectStatus['kind'], number> = { attention: 0, active: 1, idle: 2, error: 3 }
85
86// Needs attention first, then active, then idle; most recent commit first within each.
87export function sortCards(cards: readonly ProjectCard[]): ProjectCard[] {
88 return [...cards].sort(
89 (a, b) =>
90 RANK[a.status.kind] - RANK[b.status.kind] ||
91 (b.facts?.lastCommitAt ?? -1) - (a.facts?.lastCommitAt ?? -1),
92 )
93}
94
95// The card's facts line by line, without the name and the chip.
96export function factLines(facts: ProjectFacts, nowSec: number): string[] {
97 const push = !facts.hasRemote
98 ? 'no remote'
99 : [
100 facts.unpushed ? `${facts.unpushed} not pushed` : 'all pushed',
101 !facts.hasUpstream && `${facts.branch} has no upstream`,
102 ]
103 .filter(Boolean)
104 .join(', ')
105 return [
106 [
107 `${facts.commits24h} today`,
108 `${plural(facts.branches, 'branch', 'branches')} (${facts.activeBranches} active)`,
109 facts.worktrees > 0 && plural(facts.worktrees, 'worktree'),
110 ]
111 .filter(Boolean)
112 .join(' · '),
113 `${facts.dirty} uncommitted · ${push}`,
114 facts.lastCommitAt === null
115 ? 'No commits yet'
116 : `${age(Math.max(0, nowSec - facts.lastCommitAt))}: ${facts.lastSubject || '(no subject)'}`,
117 ]
118}
119types/index.d.ts 87 lines1export type Commit = {
2 hash: string
3 parents: string[]
4 refs: string[]
5 author: string
6 when: string
7 date: string
8 subject: string
9 isWorking?: boolean
10}
11
12export type Snapshot = {
13 repo: string
14 branch: string
15 head: string
16 commits: Commit[]
17 dirty: number
18 // `git status --porcelain` lines ("XY path"), the first MAX_FILES of them.
19 files: string[]
20 ahead: number | null
21 behind: number | null
22 branches: string[]
23 worktreeBranches: string[]
24 isTruncated: boolean
25 loadedAt: number
26}
27
28export type LoadError = { repo: string; message: string }
29
30// The last commit or push run from the pane, and how it went.
31export type GitAction = { kind: 'busy' | 'ok' | 'error'; text: string }
32
33// One project's activity, read with read-only git commands.
34export type ProjectFacts = {
35 // git's toplevel for the listed folder.
36 root: string
37 branch: string
38 commits24h: number
39 branches: number
40 // Local branches with a commit in the last few days (ACTIVE_BRANCH_DAYS).
41 activeBranches: number
42 // Linked worktrees; the main checkout is not counted.
43 worktrees: number
44 dirty: number
45 hasRemote: boolean
46 // Commits on local branches that no remote has; null when there is no remote.
47 unpushed: number | null
48 // Whether the checked-out branch tracks an upstream.
49 hasUpstream: boolean
50 lastSubject: string
51 // Unix seconds of the newest commit on any local branch; null with no commits.
52 lastCommitAt: number | null
53}
54
55export type ProjectStatus = {
56 kind: 'attention' | 'active' | 'idle' | 'error'
57 label: string
58 reasons: string[]
59}
60
61export type ProjectCard = {
62 // The folder as listed.
63 path: string
64 facts: ProjectFacts | null
65 error: string | null
66 status: ProjectStatus
67}
68
69declare module 'claude-code' {
70 interface PluginState {
71 'git-graph': {
72 repo: string
73 repos: string[]
74 snapshot: Snapshot | null
75 error: LoadError | null
76 isPicking: boolean
77 portfolio: ProjectCard[]
78 portfolioAt: number
79 isPortfolioLoading: boolean
80 isPortfolioEditing: boolean
81 // How many commits the graph loads; "Show more" raises it.
82 limit: number
83 action: GitAction | null
84 }
85 }
86}
87