L'albero di git in un pannello: rami locali e di GitHub, merge, dettaglio dei commit e comandi con conferma

Un pannello «Albero di git» che racconta la storia del repository come una mappa della metropolitana. Ogni ramo è una linea con il suo nome e il suo colore. Si vede da dove nasce, dove rientra con un merge e che fine ha fatto. Comprende i rami locali e quelli su GitHub.
Si sceglie un commit con i tasti e se ne leggono il messaggio e i file cambiati. Da lì si può passare a un ramo o crearne uno, sempre dopo una conferma.
/git-tree apre il pannello sul repository della cartella della sessione. Qui sotto c'è il pannello vero, disegnato a caratteri su un repository con quattro pull request unite (nel terminale ogni ramo ha il suo colore):
◆ ALBERO DI GIT · demo-repository ↓ scarica ↻ aggiorna
╭────────────────────────────────────────────────────────────────────────────────────────╮
│ SEI SU ⎇ main ☁ allineato con GitHub ✎ 1 file modificato │
│ ultimo commit 27 lug · 43 commit · 5 rami │
╰────────────────────────────────────────────────────────────────────────────────────────╯
▍Rami 1–9 salta al ramo
26 lug 27 lug
1 ▶ main ●━━━━━━━━━━━━━━━━━━━━━━━━◉ sei qui · 7 commit
2 docs/manuale-in… ┗┛ unito #4 · 1 commit
3 docs/convenzioni ┗━━┛ unito #3 · 4 commit · ☁ resta su GitHub
4 docs/guida-codi… ┗━━┛ unito #2 · 3 commit
5 chore/p3-cleanup ┗━━━━━━━━━━━━━━━━┛ unito #1 · 28 commit
▍Commit j giù k su o apri/chiudi h dove sei
── lunedì 27 luglio ────────────────────────────────────────────────────────────
▶ ◉━┓ main ☁ qui ⇠ unisce docs/manuale-introduttivo (#4) lbianchi 17:35
┃ ┃ ╭───────────────────────────────────────────────────────────────────────────────╮
┃ ┃ │ 3f1c9e7a52b80d46e1a7c09b28f5d6e4a1b7c3d9 · lbianchi · lun 27 lug 2026, 17:35 │
┃ ┃ │ │
┃ ┃ │ Merge pull request #4 from lbianchi/docs/manuale-introduttivo │
┃ ┃ │ docs: integra il manuale introduttivo nella knowledge │
┃ ┃ │ │
┃ ┃ │ 9 file · +635 −23 │
┃ ┃ │ +333 −0 docs/archivio/manuale-introduttivo.md │
┃ ┃ │ +bin −bin docs/archivio/manuale-introduttivo.pdf │
┃ ┃ │ +86 −3 docs/knowledge/00-panorama.md │
┃ ┃ │ +39 −4 docs/knowledge/02-convenzioni-codice.md │
┃ ┃ │ +48 −2 docs/knowledge/05-ai.md │
┃ ┃ │ +46 −0 docs/knowledge/08-decisioni.md │
┃ ┃ │ +11 −1 docs/knowledge/09-trappole.md │
┃ ┃ │ +53 −5 docs/knowledge/10-guida-codice.md │
┃ ┃ │ … e un altro file │
┃ ┃ │ │
┃ ┃ │ ⑂ nuovo ramo qui ⧉ copia hash ✦ chiedi a Claude │
┃ ┃ ╰───────────────────────────────────────────────────────────────────────────────╯
┃ ● docs/manuale-intr… docs: integra il manuale introduttivo nella kn… Giorgio 17:32
◆━┻━┓ main ⇠ unisce docs/convenzioni (#3) lbianchi 17:06
┃ ● docs/convenzioni ☁ docs: regole per i nomi dei file, e due esempi… Giorgio 17:02
┃ ┇ docs/convenzioni 3 commit lunedì 27 luglio Giorgio
◆━┳━┛ main ⇠ unisce docs/guida-codice (#2) lbianchi 15:33
┃ ┇ docs/guida-codice 3 commit lunedì 27 luglio Giorgio
◆━┻━┓ main ⇠ unisce chore/p3-cleanup (#1) lbianchi 14:16
┃ ┇ chore/p3-cleanup 28 commit sabato 25 luglio → lunedì 27 luglio Giorgio
┃ ┃ ── giovedì 5 marzo ─────────────────────────────────────────────────────────────
●━━━┛ main Add search filters, user settings, and export/… Giorgio 17:48
┃ ── 11 dicembre 2025 ────────────────────────────────────────────────────────────────
● main Release v0.2.0: login improvements, UI fixes, and … Giorgio 14:52
┃ ── 6 dicembre 2025 ─────────────────────────────────────────────────────────────────
● main Improve dashboard widgets and fix UI issues Giorgio 10:05
Il pannello ha tre parti.
Sei su. Il ramo su cui sei è un'etichetta nel suo colore. Accanto c'è il suo stato rispetto a GitHub:
A destra ci sono i file modificati o nuovi, non ancora committati. Sotto: quanto è vecchio il commit su cui sei, quanti commit e quanti rami sono letti.
Rami. Una riga per ramo, al massimo nove, a partire da quello su cui sei. Ogni ramo è disegnato su una linea del tempo comune:
┗━━┛ è nato da un altro ramo ed è stato unito;┗━━● è ancora aperto;◉; un ramo che nella storia letta non nasce da un altro comincia con ●.La linea del tempo va per numero di commit, non per calendario: un periodo di lavoro fitto prende spazio, un anno di silenzio no. Fino a quattro date sopra dicono a quando corrisponde ogni punto; una data che toccherebbe la vicina, o che si ripete, è lasciata fuori. A destra c'è che fine ha fatto il ramo:
Il tasto con la cifra del ramo porta la mappa al suo ultimo commit.
Commit. La mappa, dal più recente:
● è un commit, ◉ quello su cui sei (con l'etichetta «qui»), ◆ un merge;┻ e ┛ segnano dove un ramo nasce da quello a sinistra; ┓ e ┳ dove un merge apre la linea del ramo unito; ┫ e ┣ dove un merge si aggancia a una linea già disegnata; ╋ dove due linee si incrociano;☁ quando GitHub ha lo stesso commit, «☁ GitHub è qui» quando la copia su GitHub è rimasta indietro;⎇ ramo per un ramo locale, ☁ ramo per uno su GitHub, ◇ tag per un tag;Tre o più commit di fila dello stesso ramo, senza niente di speciale (nessun nome di ramo o tag, nessun ramo che nasce o rientra), si chiudono in una riga. Il commit selezionato e quello su cui sei non finiscono mai dentro un gruppo. Esempio: ┇ chore/p3-cleanup 28 commit sabato 25 luglio → lunedì 27 luglio. Selezionata, o la apre; o su uno dei suoi commit la richiude.
Il commit selezionato si apre in una scheda sotto la sua riga, incorniciata nel colore del suo ramo, con accanto le linee che proseguono. La scheda mostra:
… se la riga è stretta);… se ce ne sono altre;Un clic su una riga la seleziona.
| Tasto | Cosa fa |
|---|---|
j / k | Riga successiva / precedente (commit o gruppo ripiegato) |
o | Apre il gruppo selezionato; sopra uno dei suoi commit lo richiude |
1–9 | Va all'ultimo commit del ramo con quel numero nel panorama |
h | Torna al commit su cui sei |
p | Passa al primo ramo o commit proposto nella scheda (chiede conferma); non c'è quando hai file non committati |
b | Nuovo ramo sul commit selezionato: scrivi il nome e premi Invio |
y | Copia l'hash intero |
c | Chiede a Claude di spiegare il commit |
f | Scarica da GitHub (chiede conferma) |
r | Rilegge il repository |
m | Legge altri 200 commit |
s / n | Sì, esegui / Annulla, quando c'è una domanda aperta |
Git non registra a quale ramo appartiene un commit: lo ricostruisce la mod. Dalla cima di ogni ramo risale lungo i primi genitori finché trova un commit già assegnato, in quest'ordine:
main, master, develop o trunk), prima locale poi su GitHub;Così un ramo prende solo i commit fatti dopo essere nato, e la storia sotto resta del ramo principale. I commit che nessuna cima raggiunge prendono il nome dal merge che li ha uniti: «Merge pull request #3 from utente/docs/convenzioni» dà docs/convenzioni, «Merge branch 'x'» dà x. Se il merge non nomina un ramo, il tratto si chiama «unito in abc1234». Restano «senza nome» solo i commit che nessun merge ha unito. Un nome già usato prende « (2)», « (3)» e così via.
Ogni comando che cambia qualcosa mostra prima che cosa fa, in parole, e il comando esatto; parte solo con «Sì, esegui» (s). Dopo, il pannello dice com'è andata e rilegge il repository.
| Pulsante | Comando | Quando c'è |
|---|---|---|
| «⇄ passa a feature/x» | git switch feature/x | Per ogni ramo locale sul commit, tranne quello su cui sei |
| «⇄ passa a fix» | git switch --create fix --track origin/fix | Per un ramo che sta solo su GitHub: crea il ramo locale collegato e ci passa |
| «⇄ passa a questo commit» | git switch --detach <hash> | Su un commit senza rami: ci passa senza ramo (HEAD staccata) |
| «⑂ nuovo ramo qui» | git branch <nome> <hash> | Sempre; resti dove sei. Il nome scritto e confermato con Invio vale come conferma |
| «↓ scarica» | git fetch --all | Sempre; non tocca i tuoi file né i tuoi rami locali |
Con file modificati o nuovi non committati, la scheda non propone di cambiare ramo: chiede prima di committarli o di metterli da parte con git stash.
Se sei su una HEAD staccata, la conferma per cambiare ramo avverte che i commit fatti lì e non su un ramo resteranno solo nel reflog (git reflog).
Un comando alla volta: finché uno è in corso, «Sì, esegui» non ne lancia un altro.
Il nome di un nuovo ramo non può cominciare con -, contenere spazi o essere quello di un ramo che esiste già; poi git check-ref-format --branch controlla che git lo accetti.
Non ci sono push, pull, merge, rebase, reset né cancellazione di rami.
git log --all --topo-order (rami locali, rami remoti e tag, senza lo stash), git rev-parse, git symbolic-ref, git for-each-ref, git rev-list --left-right --count @{upstream}...HEAD, git status --porcelain, e per il commit selezionato git show -s --format=%B e git show --numstat (per un merge, i file rispetto al primo genitore).git show, senza commit, push o cambi di ramo. Il messaggio del commit non entra nella richiesta: è testo del repository, scritto da chiunque, e Claude lo legge come dato.GIT_OPTIONAL_LOCKS=0, così la lettura non blocca l'indice mentre Claude fa un commit, e con GIT_TERMINAL_PROMPT=0, così uno scaricamento che chiede la password fallisce subito invece di restare appeso.git 2.31 o più recente (per --diff-merges).↓ scarica, l'accesso al remoto già configurato in git.m ne aggiunge altri 200 alla volta. Una linea il cui commit di partenza è oltre il limite arriva fino in fondo, e nel panorama il suo ramo comincia con ● invece che con ┗.origin sono mostrati senza origin/; quelli di altri remoti hanno il nome intero.….r riprova.git fetch 2 minuti.claude plugin marketplace add Wowbagger1994/claude-mods
claude plugin install git-tree@enrik-mods
Oppure, dal prompt di Claude Code: /plugin install git-tree --marketplace Wowbagger1994/claude-mods.
Dalla cartella della mod:
claude plugin validate .
claude plugin test .
Oggi i test sono 62.
hooks/register.tsx 355 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Commit, CommitDetail, HeadState } from '../types'
4import type { BranchInfo } from './branches'
5import { branchesOf, branchInfos } from './branches'
6import type { GitAction } from './commands'
7import { branchAction, branchNameProblem, explainPrompt, FETCH } from './commands'
8import type { Item } from './fold'
9import { isSelectable, itemsOf } from './fold'
10import type { GraphRow } from './graph'
11import { layout } from './graph'
12import { changedCount, drawable, logArgs, parseAheadBehind, parseLog, parseNumstat } from './log'
13import type { Actions } from './view'
14import { treeView } from './view'
15
16const PLUGIN = 'git-tree'
17const PANE = 'git-tree'
18const TITLE = 'Albero di git'
19const GIT_TIMEOUT_MS = 15_000
20const FETCH_TIMEOUT_MS = 120_000
21/** Commits read at first, and added by each «mostra altri». */
22const PAGE = 200
23const ERROR_LINES = 3
24const MIN_COLUMNS = 40
25
26type State = {
27 root: string | null
28 /** Why there is no tree to show. */
29 problem: string | null
30 commits: Commit[]
31 rows: GraphRow[]
32 /** Each commit's branch, and the branches' stories. */
33 owner: ReadonlyMap<string, string>
34 infos: BranchInfo[]
35 /** Folded runs the person opened, by their key: the commits they hold. */
36 opened: ReadonlyMap<string, readonly string[]>
37 headSha: string | null
38 head: HeadState
39 localNames: ReadonlySet<string>
40 limit: number
41 selected: string | null
42 details: ReadonlyMap<string, CommitDetail>
43 pending: GitAction | null
44 isNaming: boolean
45 nameProblem: string | null
46 busy: string | null
47 message: { text: string; isError: boolean } | null
48 /** A commit whose detail git could not give: asked again on the next reading. */
49 failedDetail: string | null
50}
51
52const INITIAL: State = {
53 root: null,
54 problem: null,
55 commits: [],
56 rows: [],
57 owner: new Map(),
58 infos: [],
59 opened: new Map(),
60 headSha: null,
61 head: { branch: null, ahead: null, behind: null, changed: 0 },
62 localNames: new Set(),
63 limit: PAGE,
64 selected: null,
65 details: new Map(),
66 pending: null,
67 isNaming: false,
68 nameProblem: null,
69 busy: null,
70 message: null,
71 failedDetail: null,
72}
73
74/** The pane's state: replaced whole on each change, never changed in place. */
75let state: State = INITIAL
76
77/** Each reading of the tree takes a number: one overtaken by a later one is dropped. */
78let loadSeq = 0
79
80/**
81 * No optional lock on the index while Claude may be committing; no
82 * credential prompt that would hang a fetch with nobody to answer it.
83 */
84const GIT_ENV = { GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' }
85
86/** The map's lines as the state draws them; the selected commit and where you are never fold. */
87const itemsNow = (): Item[] =>
88 itemsOf(state.commits, state.rows, state.owner, new Set(state.opened.keys()), new Set([state.selected, state.headSha].filter((x): x is string => x !== null)))
89
90const isFold = (key: string) => key.startsWith('fold:')
91
92const set = ($: EngineInterface, patch: Partial<State>) => {
93 state = { ...state, ...patch }
94 $.ui.invalidate('ui.render')
95}
96
97const log = ($: EngineInterface, what: string) => (error: unknown) => $.ui.log(`${PLUGIN}: ${what}: ${String(error)}`, { to: 'debug' })
98
99type Ran = { exitCode: number; stdout: string; stderr: string }
100
101const git = ($: EngineInterface, root: string, args: readonly string[], timeoutMs = GIT_TIMEOUT_MS): Promise<Ran> =>
102 $.process.run(['git', '-C', root, ...args], { cwd: root, timeoutMs, env: GIT_ENV }).catch((cause: unknown): Ran => ({ exitCode: -1, stdout: '', stderr: String(cause) }))
103
104const isPaneShown = async ($: EngineInterface) => (await $.ui.panes()).some(p => p.id === PANE && p.isShown)
105
106/** The repository of the session's folder; null outside one. */
107const rootOf = async ($: EngineInterface): Promise<string | null> => {
108 const dir = await $.session.root()
109 const top = await $.process.run(['git', '-C', dir, 'rev-parse', '--show-toplevel'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS, env: GIT_ENV }).catch(() => null)
110 return top === null || top.exitCode !== 0 ? null : top.stdout.trim()
111}
112
113/** Reads a commit's message and files once; kept while the pane lives. */
114const loadDetail = async ($: EngineInterface, sha: string) => {
115 const root = state.root
116 if (root === null || state.details.has(sha)) return
117 const [message, numstat] = await Promise.all([
118 git($, root, ['show', '-s', '--format=%B', sha]),
119 git($, root, ['show', '--numstat', '--format=', '--diff-merges=first-parent', '-M', sha]),
120 ])
121 // A failed reading is not kept: the next one asks git again.
122 if (message.exitCode !== 0 || numstat.exitCode !== 0) {
123 set($, { failedDetail: sha })
124 return
125 }
126 const detail: CommitDetail = { sha, body: drawable(message.stdout), files: parseNumstat(numstat.stdout) }
127 set($, { details: new Map(state.details).set(sha, detail), failedDetail: state.failedDetail === sha ? null : state.failedDetail })
128}
129
130const headOf = async ($: EngineInterface, root: string): Promise<HeadState> => {
131 const [branch, counts, status] = await Promise.all([
132 git($, root, ['symbolic-ref', '--short', '-q', 'HEAD']),
133 git($, root, ['rev-list', '--left-right', '--count', '@{upstream}...HEAD']),
134 git($, root, ['status', '--porcelain']),
135 ])
136 return {
137 branch: branch.exitCode === 0 ? branch.stdout.trim() : null,
138 ...(counts.exitCode === 0 ? parseAheadBehind(counts.stdout) : { ahead: null, behind: null }),
139 changed: changedCount(status.stdout),
140 }
141}
142
143/** Reads the whole tree again: commits, where HEAD is, the local branches. */
144const load = async ($: EngineInterface) => {
145 const seq = ++loadSeq
146 const root = await rootOf($)
147 if (seq !== loadSeq) return
148 if (root === null) {
149 set($, { root: null, problem: 'La cartella di questa sessione non è in un repository git.' })
150 return
151 }
152 // Another repository: nothing of the last one holds.
153 if (root !== state.root) state = { ...INITIAL, root }
154 const [logged, headSha, locals, head] = await Promise.all([
155 git($, root, logArgs(state.limit)),
156 git($, root, ['rev-parse', '-q', '--verify', 'HEAD']),
157 git($, root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
158 headOf($, root),
159 ])
160 if (seq !== loadSeq) return
161 if (logged.exitCode !== 0 && !/does not have any commits|bad default revision/.test(logged.stderr)) {
162 set($, { root, problem: `git log non riesce: ${logged.stderr.trim().split('\n')[0] ?? ''}` })
163 return
164 }
165 const commits = parseLog(logged.stdout)
166 const sha = headSha.exitCode === 0 ? headSha.stdout.trim() : null
167 const owner = branchesOf(commits)
168 const infos = branchInfos(commits, owner, head.branch ?? 'HEAD staccata')
169 const colors = new Map(infos.map(b => [b.name, b.color]))
170 const colorOf = (c: string) => colors.get(owner.get(c) ?? '') ?? -1
171 const kept = state.selected
172 const isKept = kept !== null && commits.some(c => c.sha === (isFold(kept) ? kept.slice('fold:'.length) : kept))
173 const selected = isKept ? kept : ((commits.find(c => c.sha === sha) ?? commits[0])?.sha ?? null)
174 set($, {
175 root,
176 problem: null,
177 commits,
178 rows: layout(commits, sha, colorOf),
179 owner,
180 infos,
181 headSha: sha,
182 head,
183 localNames: new Set(locals.stdout.split('\n').map(l => l.trim()).filter(l => l !== '')),
184 selected,
185 // A question about another commit no longer holds.
186 ...(selected === state.selected ? {} : { pending: null, isNaming: false, nameProblem: null }),
187 failedDetail: null,
188 })
189 // A fold the new history no longer makes: its first commit stands in for it.
190 if (selected !== null && isFold(selected) && !itemsNow().some(i => i.key === selected)) state = { ...state, selected: selected.slice('fold:'.length) }
191 if (state.selected !== null && !isFold(state.selected)) await loadDetail($, state.selected)
192}
193
194/** Brings the opened card, then its row, into the pane's window. */
195const reveal = async ($: EngineInterface, sha: string) => {
196 await $.ui.scroll({ to: { key: 'card' }, in: PANE }).catch(log($, 'scheda non mostrata'))
197 await $.ui.scroll({ to: { key: `row:${sha}` }, in: PANE }).catch(log($, 'riga non mostrata'))
198}
199
200/** Selects a line: a commit, whose detail is read, or a folded run. */
201const select = async ($: EngineInterface, key: string) => {
202 set($, { selected: key, pending: null, isNaming: false, nameProblem: null })
203 if (!isFold(key)) await loadDetail($, key)
204 await reveal($, key)
205}
206
207/** Opens the selected run on its first commit; on a commit of an opened run, folds it back. */
208const toggleFold = async ($: EngineInterface) => {
209 const key = state.selected
210 if (key === null) return
211 if (isFold(key)) {
212 const run = itemsNow().find((i): i is Extract<Item, { kind: 'fold' }> => i.kind === 'fold' && i.key === key)
213 if (run === undefined) return
214 const shas = state.commits.slice(run.from, run.to + 1).map(c => c.sha)
215 set($, { opened: new Map(state.opened).set(key, shas) })
216 await select($, shas[0]!)
217 return
218 }
219 const owning = [...state.opened].find(([, shas]) => shas.includes(key))
220 if (owning === undefined) return
221 const opened = new Map(state.opened)
222 opened.delete(owning[0])
223 set($, { opened })
224 await select($, owning[0])
225}
226
227/** Runs a confirmed command, says how it went, reads the tree again. */
228const runAction = async ($: EngineInterface, action: GitAction) => {
229 const root = state.root
230 if (root === null) return
231 const command = `git ${action.args.join(' ')}`
232 set($, { pending: null, isNaming: false, nameProblem: null, busy: `eseguo ${command}…`, message: null })
233 const ran = await git($, root, action.args, action.args[0] === 'fetch' ? FETCH_TIMEOUT_MS : GIT_TIMEOUT_MS)
234 const why = `${ran.stderr}\n${ran.stdout}`.trim().split('\n').filter(l => l.trim() !== '').slice(0, ERROR_LINES).join(' · ')
235 set($, {
236 busy: null,
237 message: ran.exitCode === 0 ? { text: `✓ Fatto: ${command}`, isError: false } : { text: `✗ ${command} non è riuscito: ${why}`, isError: true },
238 })
239 await load($)
240}
241
242const submitBranch = async ($: EngineInterface, name: string) => {
243 const root = state.root
244 const commit = state.commits.find(c => c.sha === state.selected)
245 if (root === null || commit === undefined) return
246 const problem = branchNameProblem(name, state.localNames)
247 if (problem !== null) {
248 set($, { nameProblem: problem })
249 return
250 }
251 const checked = await git($, root, ['check-ref-format', '--branch', name])
252 if (checked.exitCode !== 0) {
253 set($, { nameProblem: `git non accetta «${name}» come nome di ramo.` })
254 return
255 }
256 // The name typed and sent is the person's yes.
257 await runAction($, branchAction(name, commit))
258}
259
260/** While a branch name is typed, the pane's other keys wait. */
261const unlessNaming =
262 <A extends unknown[]>(fn: (...args: A) => unknown) =>
263 (...args: A) =>
264 state.isNaming ? undefined : fn(...args)
265
266const actionsFor = ($: EngineInterface): Actions => ({
267 select: unlessNaming((key: string) => select($, key).catch(log($, 'commit non aperto'))),
268 move: unlessNaming((delta: number) => {
269 const lines = itemsNow().filter(isSelectable)
270 const at = lines.findIndex(i => i.key === state.selected)
271 const next = lines[Math.min(lines.length - 1, Math.max(0, at + delta))]
272 return next === undefined ? undefined : select($, next.key).catch(log($, 'commit non aperto'))
273 }),
274 jump: unlessNaming((branch: string) => {
275 const tip = state.infos.find(b => b.name === branch)?.shas[0]
276 return tip === undefined ? undefined : select($, tip).catch(log($, 'ramo non aperto'))
277 }),
278 toggleFold: unlessNaming(() => toggleFold($).catch(log($, 'gruppo non aperto'))),
279 toHead: unlessNaming(() => (state.headSha === null ? undefined : select($, state.headSha).catch(log($, 'commit non aperto')))),
280 refresh: unlessNaming(() => load($).catch(log($, 'albero non letto'))),
281 more: unlessNaming(() => {
282 state = { ...state, limit: state.limit + PAGE }
283 return load($).catch(log($, 'albero non letto'))
284 }),
285 ask: unlessNaming((action: GitAction) => set($, { pending: action, message: null })),
286 askFetch: unlessNaming(() => set($, { pending: FETCH, message: null })),
287 startBranch: unlessNaming(() => set($, { isNaming: true, nameProblem: null, pending: null, message: null })),
288 submitBranch: name => submitBranch($, name).catch(log($, 'ramo non creato')),
289 // One command at a time.
290 confirm: () => (state.pending === null || state.busy !== null ? undefined : runAction($, state.pending).catch(log($, 'comando non eseguito'))),
291 cancel: () => set($, { pending: null, isNaming: false, nameProblem: null }),
292 copy: async (text, surface) => {
293 const copied = await $.ui.copy({ text, surface })
294 $.ui.toast(copied.isCopied ? `Copiato ${text.slice(0, 7)}` : 'Hash non copiato: questo terminale non dà accesso agli appunti')
295 },
296 explain: unlessNaming((commit: Commit) => (state.root === null ? undefined : $.prompt.submit({ text: explainPrompt(commit, state.root) }))),
297})
298
299export const register: Register = on => {
300 on('session.start', async ($, e, next) => {
301 await $.command.register({ name: 'git-tree', description: 'L’albero di git del repository: rami, merge, commit e comandi con conferma' })
302 state = INITIAL
303 return next(e)
304 })
305
306 on('command.run', { command: 'git-tree' }, async $ => {
307 await $.ui.open({ id: PANE, title: TITLE, focus: true }).catch(() => $.ui.open({ id: PANE, title: TITLE }))
308 $.clock.after(0, async () => {
309 await load($).catch(log($, 'albero non letto'))
310 if (state.selected !== null) await reveal($, state.selected)
311 })
312 return { text: 'Albero di git aperto: j e k scorrono i commit, p passa al ramo, b crea un ramo, f scarica da GitHub.' }
313 })
314
315 on('turn.complete', async ($, e, next) => {
316 const done = await next(e)
317 // Claude may have committed or changed branch: an open tree follows.
318 if (e.agentId === undefined && (await isPaneShown($))) $.clock.after(0, () => load($).catch(log($, 'albero non letto')))
319 return done
320 }).catch(($, e, next) => next(e))
321
322 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
323 const el = $.ui.resolve(e)
324 if (state.problem !== null) return <el.Text dimColor wrap="wrap">{state.problem}</el.Text>
325 if (state.root === null) return <el.Text dimColor>Leggo il repository…</el.Text>
326 const selected = state.selected
327 return treeView(
328 el,
329 {
330 name: state.root.split('/').pop() ?? state.root,
331 commits: state.commits,
332 rows: state.rows,
333 owner: state.owner,
334 infos: state.infos,
335 items: itemsNow(),
336 headSha: state.headSha,
337 head: state.head,
338 selected,
339 detail: selected === null ? null : (state.details.get(selected) ?? null),
340 localNames: state.localNames,
341 pending: state.pending,
342 isNaming: state.isNaming,
343 nameProblem: state.nameProblem,
344 isDetailFailed: selected !== null && state.failedDetail === selected,
345 busy: state.busy,
346 message: state.message,
347 hasMore: state.commits.length >= state.limit,
348 now: await $.clock.now(),
349 columns: Math.max(MIN_COLUMNS, e.props.bodyColumns),
350 },
351 actionsFor($),
352 )
353 })
354}
355hooks/branches.ts 136 lines1import type { Commit } from '../types'
2
3/** The branch every repository keeps as its trunk, first after the one you are on. */
4const TRUNKS = ['main', 'master', 'develop', 'trunk']
5
6/** A branch's place in the story: where you are, the trunk, still open, merged, only on GitHub. */
7export type BranchStatus = 'head' | 'open' | 'merged' | 'remote'
8
9export type BranchInfo = {
10 name: string
11 /** Its colour: 0 for the branch you are on, then in the order shown. */
12 color: number
13 status: BranchStatus
14 /** Newest first, as in the log. */
15 shas: string[]
16 /** The commit it grew from, on another branch; null when that is beyond the commits read. */
17 forkSha: string | null
18 /** The merge that brought it into another branch, and the pull request's number when the merge names one. */
19 mergeSha: string | null
20 pr: number | null
21 mergedInto: string | null
22 /** A GitHub copy exists under its name. */
23 isOnGitHub: boolean
24}
25
26/** The branch a merge's subject names: `Merge pull request #4 from user/x`, `Merge branch 'x'`, `Merge remote-tracking branch 'origin/x'`. */
27export const mergedBranchOf = (subject: string): string | null => {
28 const pr = /^Merge pull request #\d+ from [^/\s]+\/(\S+)/.exec(subject)
29 if (pr) return pr[1]!
30 const branch = /^Merge (?:remote-tracking )?branch '([^']+)'/.exec(subject)
31 if (branch) return branch[1]!.replace(/^origin\//, '')
32 return null
33}
34
35export const prOf = (subject: string): number | null => {
36 const m = /^Merge pull request #(\d+)/.exec(subject) ?? /\(#(\d+)\)\s*$/.exec(subject)
37 return m ? Number(m[1]) : null
38}
39
40/** What a merge did, in words: `unisce docs/convenzioni (#3)`; null for any other commit. */
41export const mergeTitle = (c: Commit): string | null => {
42 if (c.parents.length < 2) return null
43 const name = mergedBranchOf(c.subject)
44 if (name === null) return null
45 const pr = prOf(c.subject)
46 return `unisce ${name}${pr === null ? '' : ` (#${pr})`}`
47}
48
49const remoteName = (name: string) => (name.startsWith('origin/') ? name.slice('origin/'.length) : name)
50
51/**
52 * The branch names to start from, best first: the trunk, then where you are, then
53 * the other local branches, then GitHub's. The trunk goes first because a branch's
54 * first parents run on into the trunk's history below where it grew: taken first,
55 * that history would be the branch's.
56 */
57const tipsOf = (commits: readonly Commit[]): { name: string; sha: string }[] => {
58 const head = commits.flatMap(c => c.refs.filter(r => r.kind === 'head').map(r => ({ name: r.name === '' ? 'HEAD staccata' : r.name, sha: c.sha })))
59 const locals = commits.flatMap(c => c.refs.filter(r => r.kind === 'local').map(r => ({ name: r.name, sha: c.sha })))
60 const remotes = commits.flatMap(c => c.refs.filter(r => r.kind === 'remote').map(r => ({ name: remoteName(r.name), sha: c.sha })))
61 const isTrunk = (t: { name: string }) => TRUNKS.includes(t.name)
62 return [...head.filter(isTrunk), ...locals.filter(isTrunk), ...remotes.filter(isTrunk), ...head, ...locals.filter(t => !isTrunk(t)), ...remotes.filter(t => !isTrunk(t))]
63}
64
65/**
66 * Which branch each commit belongs to: from each tip, best first, down its first
67 * parents to a commit another branch already took; then each merge's other parents,
68 * named after what the merge says; whatever is left is a branch with no name.
69 */
70export const branchesOf = (commits: readonly Commit[]): Map<string, string> => {
71 const bySha = new Map(commits.map(c => [c.sha, c]))
72 const owner = new Map<string, string>()
73 const used = new Set<string>()
74 const claim = (from: string, name: string) => {
75 let sha: string | undefined = from
76 while (sha !== undefined && bySha.has(sha) && !owner.has(sha)) {
77 owner.set(sha, name)
78 sha = bySha.get(sha)!.parents[0]
79 }
80 used.add(name)
81 }
82 // A name met again (GitHub's copy ahead of the local branch) goes on taking its own commits.
83 for (const t of tipsOf(commits)) if (!owner.has(t.sha)) claim(t.sha, t.name)
84 const unique = (name: string) => {
85 if (!used.has(name)) return name
86 let n = 2
87 while (used.has(`${name} (${n})`)) n++
88 return `${name} (${n})`
89 }
90 for (const c of commits) {
91 if (!owner.has(c.sha)) claim(c.sha, unique('senza nome'))
92 for (const p of c.parents.slice(1)) if (bySha.has(p) && !owner.has(p)) claim(p, unique(mergedBranchOf(c.subject) ?? `unito in ${c.sha.slice(0, 7)}`))
93 }
94 return owner
95}
96
97/** Each branch's story, the one you are on first, then by its newest commit. */
98export const branchInfos = (commits: readonly Commit[], owner: ReadonlyMap<string, string>, headBranch: string | null): BranchInfo[] => {
99 const bySha = new Map(commits.map(c => [c.sha, c]))
100 const order: string[] = []
101 const shas = new Map<string, string[]>()
102 for (const c of commits) {
103 const name = owner.get(c.sha)
104 if (name === undefined) continue
105 if (!shas.has(name)) order.push(name)
106 shas.set(name, [...(shas.get(name) ?? []), c.sha])
107 }
108 const merges = new Map<string, Commit>()
109 for (const c of commits) for (const p of c.parents.slice(1)) {
110 const name = owner.get(p)
111 // The newest merge of a branch's tip is the one that took it all.
112 if (name !== undefined && shas.get(name)?.[0] === p && !merges.has(name)) merges.set(name, c)
113 }
114 const remoteNames = new Set(commits.flatMap(c => c.refs.filter(r => r.kind === 'remote').map(r => remoteName(r.name))))
115 const localNames = new Set(commits.flatMap(c => c.refs.filter(r => r.kind === 'local' || r.kind === 'head').map(r => r.name)))
116 const sorted = [...order].sort((a, b) => Number(b === headBranch) - Number(a === headBranch) || Number(TRUNKS.includes(b)) - Number(TRUNKS.includes(a)))
117 return sorted.map((name, color) => {
118 const own = shas.get(name)!
119 const oldest = bySha.get(own[own.length - 1]!)!
120 const fork = oldest.parents[0]
121 const merge = merges.get(name) ?? null
122 const status: BranchStatus = name === headBranch ? 'head' : merge !== null ? 'merged' : !localNames.has(name) && remoteNames.has(name) ? 'remote' : 'open'
123 return {
124 name,
125 color,
126 status,
127 shas: own,
128 forkSha: fork !== undefined && bySha.has(fork) ? fork : null,
129 mergeSha: merge?.sha ?? null,
130 pr: merge === null ? null : prOf(merge.subject),
131 mergedInto: merge === null ? null : (owner.get(merge.sha) ?? null),
132 isOnGitHub: remoteNames.has(name),
133 }
134 })
135}
136hooks/commands.ts 70 lines1import type { Commit, Ref } from '../types'
2import { shortRef } from './labels'
3
4/** A git command the person confirms before it runs: what it does, in words. */
5export type GitAction = { label: string; args: string[]; explain: string }
6
7const short = (sha: string) => sha.slice(0, 7)
8
9/**
10 * Where the person can go from a commit: each local branch on it but the one
11 * checked out, each GitHub branch with no local one of its name (made, then
12 * entered), else the commit itself with no branch.
13 */
14export const switchActions = (commit: Commit, localNames: ReadonlySet<string>, headSha: string | null, isDetached: boolean): GitAction[] => {
15 // A name with a leading dash would reach git as an option: git never makes one, a hand-made ref could.
16 const isPlain = (name: string) => !name.startsWith('-')
17 const locals = commit.refs.filter(r => r.kind === 'local' && isPlain(r.name)).map(r => r.name)
18 const remotes = commit.refs
19 .filter((r): r is Ref & { kind: 'remote' } => r.kind === 'remote' && r.name.includes('/'))
20 .map(r => ({ remote: r.name, name: r.name.slice(r.name.indexOf('/') + 1) }))
21 .filter(r => isPlain(r.name) && !localNames.has(r.name))
22 // Leaving a detached HEAD strands the commits made there that no branch holds.
23 const leaving = isDetached ? ` Ora sei su una HEAD staccata: i commit fatti lì e non su un ramo resteranno solo nel reflog (git reflog).` : ''
24 const actions: GitAction[] = [
25 ...locals.map(name => ({ label: `⇄ passa a ${shortRef(name)}`, args: ['switch', name], explain: `Passi al ramo ${name}.${leaving}` })),
26 ...remotes.map(r => ({
27 label: `⇄ passa a ${shortRef(r.name)}`,
28 args: ['switch', '--create', r.name, '--track', r.remote],
29 explain: `Crei il ramo locale ${r.name}, collegato a ${r.remote} su GitHub, e ci passi.${leaving}`,
30 })),
31 ]
32 if (actions.length > 0 || commit.sha === headSha) return actions
33 return [
34 {
35 label: '⇄ passa a questo commit',
36 args: ['switch', '--detach', commit.sha],
37 explain: `Passi al commit ${short(commit.sha)} senza un ramo (HEAD staccata). Per tenere i commit che farai lì, crea poi un ramo.${leaving}`,
38 },
39 ]
40}
41
42/** Why a branch name cannot be used, before git is asked; null when it may. */
43export const branchNameProblem = (name: string, localNames: ReadonlySet<string>): string | null => {
44 if (name === '') return 'Scrivi un nome.'
45 // A leading dash would reach git as an option.
46 if (name.startsWith('-')) return 'Il nome non può cominciare con «-».'
47 if (/\s/.test(name)) return 'Il nome non può contenere spazi.'
48 if (localNames.has(name)) return `Il ramo ${name} esiste già.`
49 return null
50}
51
52export const branchAction = (name: string, commit: Commit): GitAction => ({
53 label: `⑂ ${name}`,
54 args: ['branch', name, commit.sha],
55 explain: `Crei il ramo ${name} sul commit ${short(commit.sha)}. Resti dove sei.`,
56})
57
58export const FETCH: GitAction = {
59 label: '↓ Scarica da GitHub',
60 args: ['fetch', '--all'],
61 explain: 'Scarichi da GitHub i rami e i commit nuovi. Non cambia i tuoi file né i tuoi rami locali.',
62}
63
64/**
65 * The prompt that asks Claude to explain a commit: its hash and the repository
66 * only. The message is the repository's text, anyone's: Claude reads it as data, with git show.
67 */
68export const explainPrompt = (commit: Commit, root: string) =>
69 `Spiegami il commit ${short(commit.sha)} del repository ${root}: che cosa cambia e perché. Leggilo con git show. Non fare commit né push e non cambiare ramo.`
70hooks/fold.ts 69 lines1import type { Commit } from '../types'
2import type { GraphRow } from './graph'
3import { dayKey } from './labels'
4
5/** This many plain commits in a row, or more, fold into one line. */
6export const MIN_RUN = 3
7
8/** What the map draws, top to bottom: a day's heading, a commit, a run of commits folded into one. */
9export type Item =
10 | { kind: 'day'; key: string; at: number; index: number }
11 | { kind: 'commit'; key: string; index: number }
12 | { kind: 'fold'; key: string; from: number; to: number }
13
14export const foldKey = (sha: string) => `fold:${sha}`
15
16/** Runs of plain commits of one branch, back to back: [from, to] by row. */
17const runsOf = (commits: readonly Commit[], rows: readonly GraphRow[], owner: ReadonlyMap<string, string>, keep: ReadonlySet<string>): [number, number][] => {
18 const isFoldable = (i: number) => rows[i]?.isPlain === true && !keep.has(commits[i]!.sha)
19 const runs: [number, number][] = []
20 let start = -1
21 commits.forEach((c, i) => {
22 const goesOn = start >= 0 && isFoldable(i) && owner.get(c.sha) === owner.get(commits[start]!.sha) && rows[i]!.col === rows[start]!.col
23 if (goesOn) return
24 if (start >= 0 && i - start >= MIN_RUN) runs.push([start, i - 1])
25 start = isFoldable(i) ? i : -1
26 })
27 if (start >= 0 && commits.length - start >= MIN_RUN) runs.push([start, commits.length - 1])
28 return runs
29}
30
31/**
32 * The map's lines: commits, long plain runs folded unless opened, and a day's
33 * heading before the first line of each older day. Commits in `keep` (the
34 * selected one, where you are) never fold.
35 */
36export const itemsOf = (
37 commits: readonly Commit[],
38 rows: readonly GraphRow[],
39 owner: ReadonlyMap<string, string>,
40 opened: ReadonlySet<string>,
41 keep: ReadonlySet<string>,
42): Item[] => {
43 const runs = runsOf(commits, rows, owner, keep).filter(([from]) => !opened.has(foldKey(commits[from]!.sha)))
44 const runAt = new Map(runs.map(r => [r[0], r]))
45 const items: Item[] = []
46 let lastDay: string | null = null
47 for (let i = 0; i < commits.length; i++) {
48 const run = runAt.get(i)
49 const c = commits[i]!
50 const day = dayKey(c.at)
51 // Topological order wanders in time: a heading only when the day goes back.
52 if (lastDay === null || day < lastDay) {
53 items.push({ kind: 'day', key: `day:${day}:${i}`, at: c.at, index: i })
54 lastDay = day
55 }
56 if (run !== undefined) {
57 items.push({ kind: 'fold', key: foldKey(c.sha), from: run[0], to: run[1] })
58 i = run[1]
59 // The run's oldest day counts too: the next heading comes after it.
60 const oldest = dayKey(commits[run[1]]!.at)
61 if (oldest < lastDay) lastDay = oldest
62 } else items.push({ kind: 'commit', key: c.sha, index: i })
63 }
64 return items
65}
66
67/** The lines the selection can stop on. */
68export const isSelectable = (item: Item) => item.kind !== 'day'
69hooks/graph.ts 118 lines1import type { Commit } from '../types'
2
3/** The graph's strokes, heavy as a metro map's lines. */
4export const LINE = { down: '┃', across: '━', close: '┛', open: '┓', joinRight: '┫', joinLeft: '┣', cross: '╋' } as const
5export const DOT = { commit: '●', head: '◉', merge: '◆' } as const
6
7/** One character of the graph, and the lane colour it wears; -1 for none. */
8export type Cell = { ch: string; color: number }
9
10/**
11 * A commit's row: its characters, the lane its dot sits in, its branch's colour,
12 * the lanes going on below it, for what is drawn between it and the next, and
13 * whether it is plain: a commit like its neighbours, nothing starting, ending or
14 * joining there, one that may fold into a run.
15 */
16export type GraphRow = { cells: Cell[]; col: number; color: number; below: Cell[]; isPlain: boolean }
17
18/** A lane waits for a commit, its parent's, and keeps its colour from tip to base. */
19type Lane = { sha: string; color: number } | null
20
21/** Where a stroke from the dot ends: a lane closing into it, a lane it opens, a lane it joins. */
22type Stroke = { lane: number; ch: string; color: number }
23
24const BLANK: Cell = { ch: ' ', color: -1 }
25/** A stroke's end with the line going on past it, to a farther lane. */
26const THROUGH: Record<string, string> = { [LINE.close]: '┻', [LINE.open]: '┳', [LINE.joinRight]: LINE.cross, [LINE.joinLeft]: LINE.cross }
27
28const dotOf = (c: Commit, headSha: string | null) => (c.sha === headSha ? DOT.head : c.parents.length > 1 ? DOT.merge : DOT.commit)
29
30/** The same lanes taken, in the same places. */
31const isSameShape = (a: readonly Lane[], b: readonly Lane[]) => a.length === b.length && a.every((l, i) => (l === null) === (b[i] === null))
32
33/** A free lane for a merge's parent: right of the dot first, else a new one at the end. */
34const freeLane = (lanes: readonly Lane[], col: number, taken: ReadonlySet<number>) => {
35 const free = lanes.findIndex((l, i) => l === null && i > col && !taken.has(i))
36 return free >= 0 ? free : lanes.length
37}
38
39/** The lanes going on: a line where one waits, a gap where none does. */
40const belowOf = (lanes: readonly Lane[]): Cell[] =>
41 lanes.flatMap((l, i) => [...(i > 0 ? [BLANK] : []), l === null ? BLANK : { ch: LINE.down, color: l.color }])
42
43const trimmed = (lanes: readonly Lane[]): Lane[] => {
44 const last = lanes.reduce((at, l, i) => (l === null ? at : i), -1)
45 return lanes.slice(0, last + 1)
46}
47
48/** The characters of a row: the dot, the strokes from it, the lanes passing by. */
49const cellsOf = (dot: Cell, col: number, before: readonly Lane[], after: readonly Lane[], strokes: readonly Stroke[]): Cell[] => {
50 const width = Math.max(before.length, after.length, col + 1)
51 const ends = strokes.map(s => s.lane)
52 const left = Math.min(col, ...ends)
53 const right = Math.max(col, ...ends)
54 // A stroke's cells wear the colour of the nearest end beyond them.
55 const strokeColor = (x: number) => {
56 const beyond = strokes.filter(s => (x > 2 * col ? 2 * s.lane >= x : 2 * s.lane <= x)).sort((a, b) => Math.abs(a.lane - col) - Math.abs(b.lane - col))
57 return beyond[0]?.color ?? -1
58 }
59 const cells: Cell[] = []
60 for (let i = 0; i < width; i++) {
61 const stroke = strokes.find(s => s.lane === i)
62 const passing = before[i] ?? null
63 const isSpanned = i > left && i < right
64 if (i === col) cells.push(dot)
65 else if (stroke !== undefined) cells.push({ ch: isSpanned ? (THROUGH[stroke.ch] ?? stroke.ch) : stroke.ch, color: stroke.color })
66 else if (passing !== null) cells.push({ ch: isSpanned ? LINE.cross : LINE.down, color: passing.color })
67 else cells.push(isSpanned ? { ch: LINE.across, color: strokeColor(2 * i) } : BLANK)
68 if (i < width - 1) cells.push(i >= left && i < right ? { ch: LINE.across, color: strokeColor(2 * i + 1) } : BLANK)
69 }
70 return cells
71}
72
73/**
74 * The graph of commits given newest first, in topological order: one row per
75 * commit, lanes two characters apart. `colorOf` gives a commit its branch's
76 * colour; a line wears the colour of the branch it belongs to: the child's down
77 * to its first parent, the merged branch's from a merge to its other parents.
78 */
79export const layout = (commits: readonly Commit[], headSha: string | null, colorOf: (sha: string) => number = sha => commits.findIndex(c => c.sha === sha)): GraphRow[] => {
80 let lanes: Lane[] = []
81 const rows: GraphRow[] = []
82 for (const c of commits) {
83 const before = lanes
84 const waiting = lanes.flatMap((l, i) => (l?.sha === c.sha ? [i] : []))
85 const isTip = waiting.length === 0
86 // A new tip takes the first free lane, or a new one at the end.
87 const free = lanes.findIndex(l => l === null)
88 const at = isTip ? (free >= 0 ? free : lanes.length) : waiting[0]!
89 const color = colorOf(c.sha)
90 const closing = waiting.slice(1)
91 const strokes: Stroke[] = closing.map(i => ({ lane: i, ch: LINE.close, color: lanes[i]!.color }))
92
93 let after: Lane[] = lanes.map((l, i) => (closing.includes(i) ? null : l))
94 after[at] = c.parents[0] === undefined ? null : { sha: c.parents[0], color }
95 const taken = new Set(closing)
96 for (const p of c.parents.slice(1)) {
97 const joined = after.findIndex((l, i) => i !== at && l?.sha === p)
98 if (joined >= 0) {
99 strokes.push({ lane: joined, ch: joined > at ? LINE.joinRight : LINE.joinLeft, color: after[joined]!.color })
100 continue
101 }
102 const slot = freeLane(after, at, taken)
103 const owned = colorOf(p)
104 const lane = { sha: p, color: owned >= 0 ? owned : color }
105 after = slot === after.length ? [...after, lane] : after.map((l, i) => (i === slot ? lane : l))
106 taken.add(slot)
107 strokes.push({ lane: slot, ch: LINE.open, color: lane.color })
108 }
109 lanes = trimmed(after)
110 const isPlain = strokes.length === 0 && !isTip && c.parents.length === 1 && c.refs.length === 0 && c.sha !== headSha && isSameShape(trimmed(before), lanes)
111 rows.push({ cells: cellsOf({ ch: dotOf(c, headSha), color }, at, before, after, strokes), col: at, color, below: belowOf(lanes), isPlain })
112 }
113 return rows
114}
115
116/** The widest row's characters: where the text after the graph starts. */
117export const graphWidth = (rows: readonly GraphRow[]) => rows.reduce((w, r) => Math.max(w, r.cells.length), 0)
118hooks/log.ts 62 lines1import type { Commit, FileChange, HeadState, Ref } from '../types'
2
3const FIELD = '\x1f'
4/** Control characters but the newline: a label holding one is refused, and the pane goes blank. */
5const CONTROL = /[\u0000-\u0009\u000b-\u001f\u007f]/g
6
7/** Text from the repository as it may be drawn: control characters as spaces. */
8export const drawable = (text: string) => text.replace(CONTROL, ' ')
9
10/** What `git log` prints per commit, fields apart by the unit separator. */
11export const LOG_FORMAT = ['%H', '%P', '%an', '%at', '%D', '%s'].join('%x1f')
12
13/** `git log` over every local and remote branch and tag, newest first, branches kept together. */
14export const logArgs = (limit: number) => ['log', '--exclude=refs/stash', '--all', '--topo-order', '--decorate=full', `--format=${LOG_FORMAT}`, '-n', String(limit)]
15
16/** A decoration with full names: `HEAD -> refs/heads/main, refs/remotes/origin/main, tag: refs/tags/v1`. */
17export const parseRefs = (decoration: string): Ref[] =>
18 decoration
19 .split(', ')
20 .map(d => d.trim())
21 .filter(d => d !== '')
22 .flatMap((d): Ref[] => {
23 if (d === 'HEAD') return [{ kind: 'head', name: '' }]
24 if (d.startsWith('HEAD -> refs/heads/')) return [{ kind: 'head', name: d.slice('HEAD -> refs/heads/'.length) }]
25 if (d.startsWith('tag: refs/tags/')) return [{ kind: 'tag', name: d.slice('tag: refs/tags/'.length) }]
26 if (d.startsWith('refs/heads/')) return [{ kind: 'local', name: d.slice('refs/heads/'.length) }]
27 // A remote's HEAD only names its default branch, already shown.
28 if (d.startsWith('refs/remotes/') && !d.endsWith('/HEAD')) return [{ kind: 'remote', name: d.slice('refs/remotes/'.length) }]
29 return []
30 })
31
32export const parseLog = (text: string): Commit[] =>
33 text
34 .split('\n')
35 .filter(line => line.includes(FIELD))
36 .flatMap(line => {
37 const [sha = '', parents = '', author = '', at = '', decoration = '', ...subject] = line.split(FIELD)
38 if (!/^[0-9a-f]{40,64}$/.test(sha)) return []
39 return [{ sha, parents: parents.split(' ').filter(p => p !== ''), author: drawable(author), at: Number(at) || 0, subject: drawable(subject.join(FIELD)), refs: parseRefs(decoration) }]
40 })
41
42/** `git diff-tree --numstat` lines: `12\t3\tpath`, `-\t-\tpath` for a binary file, renames as git writes them. */
43export const parseNumstat = (text: string): FileChange[] =>
44 text
45 .split('\n')
46 .map(line => line.split('\t'))
47 .filter(parts => parts.length >= 3)
48 .map(([added = '', removed = '', ...path]) => ({
49 path: drawable(path.join('\t')),
50 added: added === '-' ? null : Number(added),
51 removed: removed === '-' ? null : Number(removed),
52 }))
53
54/** `git rev-list --left-right --count @{upstream}...HEAD`: behind, then ahead. */
55export const parseAheadBehind = (text: string): Pick<HeadState, 'ahead' | 'behind'> => {
56 const [behind, ahead] = text.trim().split(/\s+/).map(Number)
57 return behind === undefined || ahead === undefined || Number.isNaN(behind) || Number.isNaN(ahead) ? { ahead: null, behind: null } : { ahead, behind }
58}
59
60/** The files `git status --porcelain` lists. */
61export const changedCount = (porcelain: string) => porcelain.split('\n').filter(l => l.trim() !== '').length
62hooks/view.tsx 139 lines1import type { ElementTable, RenderSurface } from 'claude-code'
2
3import type { Commit, CommitDetail, HeadState } from '../types'
4import type { BranchInfo } from './branches'
5import type { GitAction } from './commands'
6import type { Item } from './fold'
7import type { GraphRow } from './graph'
8import { ageOf } from './labels'
9import { commitMap } from './map'
10import { laneColor } from './paint'
11import { panorama } from './panorama'
12
13export type Actions = {
14 /** Selects a line of the map: a commit by its hash, a folded run by its key. */
15 select: (key: string) => unknown
16 move: (delta: number) => unknown
17 toHead: () => unknown
18 jump: (branch: string) => unknown
19 toggleFold: () => unknown
20 refresh: () => unknown
21 more: () => unknown
22 ask: (action: GitAction) => unknown
23 askFetch: () => unknown
24 startBranch: () => unknown
25 submitBranch: (name: string) => unknown
26 confirm: () => unknown
27 cancel: () => unknown
28 copy: (text: string, surface: RenderSurface) => unknown
29 explain: (commit: Commit) => unknown
30}
31
32export type TreeView = {
33 name: string
34 commits: readonly Commit[]
35 rows: readonly GraphRow[]
36 /** Each commit's branch. */
37 owner: ReadonlyMap<string, string>
38 infos: readonly BranchInfo[]
39 items: readonly Item[]
40 headSha: string | null
41 head: HeadState
42 /** The selected line's key: a commit's hash or a fold's key. */
43 selected: string | null
44 detail: CommitDetail | null
45 localNames: ReadonlySet<string>
46 /** A command waiting for the person's yes. */
47 pending: GitAction | null
48 isNaming: boolean
49 nameProblem: string | null
50 /** The selected commit's detail could not be read. */
51 isDetailFailed: boolean
52 /** The command running now, in words. */
53 busy: string | null
54 message: { text: string; isError: boolean } | null
55 hasMore: boolean
56 now: number
57 columns: number
58}
59
60/** Where the checked-out branch stands against GitHub, in words. */
61export const syncText = (h: HeadState): { text: string; isCalm: boolean } => {
62 if (h.branch === null) return { text: 'nessun ramo: i commit fatti qui non hanno un nome', isCalm: false }
63 if (h.ahead === null || h.behind === null) return { text: 'non collegato a GitHub', isCalm: false }
64 if (h.ahead === 0 && h.behind === 0) return { text: '☁ allineato con GitHub', isCalm: true }
65 return { text: [...(h.ahead > 0 ? [`↑ ${h.ahead} da mandare`] : []), ...(h.behind > 0 ? [`↓ ${h.behind} da scaricare`] : [])].join(' · '), isCalm: false }
66}
67
68/** The card of where you are: the branch as a label in its colour, GitHub, the files not committed. */
69const hero = (el: ElementTable, s: TreeView) => {
70 const here = s.infos.find(b => b.status === 'head')
71 const color = laneColor(here?.color ?? 0)
72 const sync = syncText(s.head)
73 const last = s.commits.find(c => c.sha === s.headSha)
74 const facts = [
75 last === undefined ? null : `ultimo commit ${ageOf(last.at, s.now)}`,
76 `${s.commits.length}${s.hasMore ? '+' : ''} commit`,
77 `${s.infos.length} ${s.infos.length === 1 ? 'ramo' : 'rami'}`,
78 ].filter((f): f is string => f !== null)
79 return (
80 <el.Box key="hero" flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
81 <el.Box flexDirection="row" justifyContent="space-between" columnGap={2}>
82 <el.Text>
83 <el.Text dimColor bold>{'SEI SU '}</el.Text>
84 <el.Text bold color="inverseText" backgroundColor={s.head.branch === null ? 'warning' : color}>{` ⎇ ${s.head.branch ?? `HEAD staccata su ${s.headSha?.slice(0, 7) ?? '?'}`} `}</el.Text>
85 <el.Text>{' '}</el.Text>
86 <el.Text color={sync.isCalm ? 'success' : 'warning'}>{sync.text}</el.Text>
87 </el.Text>
88 {s.head.changed > 0 && <el.Text color="warning" bold>{`✎ ${s.head.changed} ${s.head.changed === 1 ? 'file modificato' : 'file modificati'}`}</el.Text>}
89 </el.Box>
90 <el.Text dimColor>{facts.join(' · ')}</el.Text>
91 </el.Box>
92 )
93}
94
95const header = (el: ElementTable, s: TreeView, act: Actions) => (
96 <el.Box flexDirection="row" justifyContent="space-between" columnGap={2}>
97 <el.Text>
98 <el.Text bold color="claude">◆ ALBERO DI GIT</el.Text>
99 <el.Text dimColor>{' · '}</el.Text>
100 <el.Text bold>{s.name}</el.Text>
101 </el.Text>
102 <el.Box flexDirection="row" columnGap={2}>
103 <el.Button key="fetch" label="↓ scarica" hotkey="f" plain dimColor onPress={() => act.askFetch()} />
104 <el.Button key="refresh" label="↻ aggiorna" hotkey="r" plain dimColor onPress={() => act.refresh()} />
105 </el.Box>
106 </el.Box>
107)
108
109/** A command's words, the command itself, and the yes. */
110export const confirmBox = (el: ElementTable, a: GitAction, act: Actions) => (
111 <el.Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
112 <el.Text color="warning" wrap="wrap">{a.explain}</el.Text>
113 <el.Text dimColor>{`$ git ${a.args.join(' ')}`}</el.Text>
114 <el.Box flexDirection="row" columnGap={2}>
115 <el.Button key="confirm" label="Sì, esegui" hotkey="s" variant="primary" onPress={() => act.confirm()} />
116 <el.Button key="cancel" label="Annulla" hotkey="n" plain dimColor onPress={() => act.cancel()} />
117 </el.Box>
118 </el.Box>
119)
120
121/** The fetch's question, or what the last command said. */
122const notice = (el: ElementTable, s: TreeView, act: Actions) => {
123 if (s.busy !== null) return <el.Text color="warning" italic>{`◌ ${s.busy}`}</el.Text>
124 if (s.pending !== null && s.pending.args[0] === 'fetch') return confirmBox(el, s.pending, act)
125 if (s.message !== null) return <el.Text color={s.message.isError ? 'error' : 'success'} wrap="wrap">{s.message.text}</el.Text>
126 return null
127}
128
129/** The pane: where you are, the branches over time, then the commits as a metro map. */
130export const treeView = (el: ElementTable, s: TreeView, act: Actions) => (
131 <el.Box flexDirection="column" gap={1}>
132 {header(el, s, act)}
133 {hero(el, s)}
134 {notice(el, s, act)}
135 {s.infos.length > 0 && panorama(el, s, act)}
136 {commitMap(el, s, act)}
137 </el.Box>
138)
139hooks/labels.ts 107 lines1import type { Ref } from '../types'
2
3const MINUTE_S = 60
4const HOUR_S = 60 * MINUTE_S
5const DAY_S = 24 * HOUR_S
6const WEEK_DAYS = 7
7const MONTHS = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic']
8const DAYS = ['dom', 'lun', 'mar', 'mer', 'gio', 'ven', 'sab']
9/** A branch name longer than this is cut in the middle of the row. */
10export const MAX_REF = 28
11
12/** `adesso`, `12 min fa`, `3 h fa`, `ieri`, `4 g fa`, then the date: `12 ott`, `12 ott 2024` in another year. */
13export const ageOf = (atS: number, nowMs: number): string => {
14 const s = Math.max(0, Math.floor(nowMs / 1000) - atS)
15 if (s < MINUTE_S) return 'adesso'
16 if (s < HOUR_S) return `${Math.floor(s / MINUTE_S)} min fa`
17 if (s < DAY_S) return `${Math.floor(s / HOUR_S)} h fa`
18 const days = Math.floor(s / DAY_S)
19 if (days === 1) return 'ieri'
20 if (days < WEEK_DAYS) return `${days} g fa`
21 const d = new Date(atS * 1000)
22 const year = d.getFullYear() === new Date(nowMs).getFullYear() ? '' : ` ${d.getFullYear()}`
23 return `${d.getDate()} ${MONTHS[d.getMonth()]}${year}`
24}
25
26/** `lun 12 ott 2026, 10:32`, in this machine's time. */
27export const fullDate = (atS: number): string => {
28 const d = new Date(atS * 1000)
29 const two = (n: number) => String(n).padStart(2, '0')
30 return `${DAYS[d.getDay()]} ${d.getDate()} ${MONTHS[d.getMonth()]} ${d.getFullYear()}, ${two(d.getHours())}:${two(d.getMinutes())}`
31}
32
33export const shortRef = (name: string, max = MAX_REF) => (name.length <= max ? name : `${name.slice(0, max - 1)}…`)
34
35/** What a commit's names say on its row. */
36export type Badge =
37 | { kind: 'head'; text: string; isSynced: boolean }
38 | { kind: 'local'; text: string; isSynced: boolean }
39 | { kind: 'remote'; text: string }
40 | { kind: 'tag'; text: string }
41
42/** `origin/feature/x` as `feature/x`; another remote keeps its name. */
43const remoteShown = (name: string) => (name.startsWith('origin/') ? name.slice('origin/'.length) : name)
44
45/**
46 * A commit's names, as few as tell it all: the checked-out branch first, then the
47 * local ones; a GitHub copy on the same commit as its local branch folds into it.
48 */
49export const badgesOf = (refs: readonly Ref[]): Badge[] => {
50 const remotes = refs.filter(r => r.kind === 'remote').map(r => r.name)
51 const isSynced = (name: string) => remotes.includes(`origin/${name}`)
52 const locals = refs.filter(r => r.kind === 'head' || r.kind === 'local').map(r => r.name)
53 const head = refs.find(r => r.kind === 'head')
54 return [
55 ...(head === undefined ? [] : [{ kind: 'head' as const, text: head.name === '' ? 'HEAD' : head.name, isSynced: head.name !== '' && isSynced(head.name) }]),
56 ...refs.filter(r => r.kind === 'local').map(r => ({ kind: 'local' as const, text: r.name, isSynced: isSynced(r.name) })),
57 ...remotes.filter(n => !locals.some(l => `origin/${l}` === n)).map(n => ({ kind: 'remote' as const, text: remoteShown(n) })),
58 ...refs.filter(r => r.kind === 'tag').map(r => ({ kind: 'tag' as const, text: r.name })),
59 ]
60}
61
62/** Words to lines of at most `width`, a word longer than that cut; blank lines kept. */
63export const wrapText = (text: string, width: number): string[] =>
64 text.split('\n').flatMap(paragraph => {
65 if (paragraph.trim() === '') return ['']
66 const lines: string[] = []
67 let line = ''
68 for (const word of paragraph.split(/\s+/).filter(w => w !== '')) {
69 const pieces = word.length > width ? word.match(new RegExp(`.{1,${Math.max(1, width)}}`, 'g')) ?? [word] : [word]
70 for (const piece of pieces) {
71 if (line === '') line = piece
72 else if (line.length + 1 + piece.length <= width) line = `${line} ${piece}`
73 else {
74 lines.push(line)
75 line = piece
76 }
77 }
78 }
79 return [...lines, line]
80 })
81
82const LONG_DAYS = ['domenica', 'lunedì', 'martedì', 'mercoledì', 'giovedì', 'venerdì', 'sabato']
83const LONG_MONTHS = ['gennaio', 'febbraio', 'marzo', 'aprile', 'maggio', 'giugno', 'luglio', 'agosto', 'settembre', 'ottobre', 'novembre', 'dicembre']
84
85/** The local day a moment falls on, as `2026-07-27`: days compare as strings. */
86export const dayKey = (atS: number): string => {
87 const d = new Date(atS * 1000)
88 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
89}
90
91/** `oggi`, `ieri`, `lunedì 27 luglio`, `27 luglio 2025` in another year. */
92export const dayLabel = (atS: number, nowMs: number): string => {
93 const today = dayKey(Math.floor(nowMs / 1000))
94 const key = dayKey(atS)
95 if (key === today) return 'oggi'
96 if (key === dayKey(Math.floor(nowMs / 1000) - 86_400)) return 'ieri'
97 const d = new Date(atS * 1000)
98 if (d.getFullYear() !== new Date(nowMs).getFullYear()) return `${d.getDate()} ${LONG_MONTHS[d.getMonth()]} ${d.getFullYear()}`
99 return `${LONG_DAYS[d.getDay()]} ${d.getDate()} ${LONG_MONTHS[d.getMonth()]}`
100}
101
102/** `17:35`, in this machine's time. */
103export const clockOf = (atS: number): string => {
104 const d = new Date(atS * 1000)
105 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
106}
107hooks/map.tsx 173 lines1import type { ElementTable } from 'claude-code'
2
3import type { Commit } from '../types'
4import { mergeTitle } from './branches'
5import { card } from './card'
6import type { Item } from './fold'
7import type { Cell, GraphRow } from './graph'
8import type { Badge } from './labels'
9import { badgesOf, clockOf, dayLabel, shortRef } from './labels'
10import { cut, fit, graphCells, graphRoom, laneColor } from './paint'
11import type { Actions, TreeView } from './view'
12
13const BRANCH_WIDTH = 18
14const AUTHOR_MAX = 12
15/** The row's own room besides graph and text: the marker, the gaps. */
16const ROW_FIXED = 2 + 1 + BRANCH_WIDTH + 1
17
18const badgeText = (b: Badge) => (b.kind === 'tag' ? `◇ ${shortRef(b.text)}` : b.kind === 'remote' ? `☁ ${shortRef(b.text)}` : `⎇ ${shortRef(b.text)}`)
19
20/** The names on a commit that its branch's own name does not already tell. */
21export const extraBadges = (c: Commit, branch: string | undefined): Badge[] => badgesOf(c.refs).filter(b => b.text !== branch && b.kind !== 'head')
22
23/** Whether the branch's own name sits on this commit on GitHub too. */
24const isSyncedHere = (c: Commit, branch: string | undefined) => c.refs.some(r => r.kind === 'remote' && r.name === `origin/${branch}`)
25
26const rightOf = (c: Commit) => `${cut(c.author, AUTHOR_MAX)} ${clockOf(c.at)}`
27
28/** A commit's line: the marker, the graph, the branch where it changes, what it did, who and when. */
29const commitLine = (el: ElementTable, s: TreeView, c: Commit, row: GraphRow, width: number, showBranch: boolean, act: Actions) => {
30 const branch = s.owner.get(c.sha)
31 const color = laneColor(row.color)
32 const isSelected = c.sha === s.selected
33 const isHead = c.sha === s.headSha
34 const merge = mergeTitle(c)
35 const badges = extraBadges(c, branch)
36 const right = rightOf(c)
37 const here = isHead ? ' qui ' : ''
38 const isSynced = isSyncedHere(c, branch)
39 const synced = showBranch && isSynced ? ' ☁' : ''
40 // GitHub's copy of the branch on a commit below its tip: worth a word of its own.
41 const branchText = showBranch ? `${cut(branch ?? '', BRANCH_WIDTH - synced.length)}${synced}` : isSynced ? '☁ GitHub è qui' : ''
42 const room = s.columns - ROW_FIXED - width - here.length - badges.reduce((n, b) => n + badgeText(b).length + 1, 0) - right.length - 3
43 return (
44 <el.Box key={`row:${c.sha}`} flexDirection="row" justifyContent="space-between">
45 <el.Button key={`sel:${c.sha}`} label={c.subject} plain onPress={() => act.select(c.sha)}>
46 <el.Text color={color} bold>{isSelected ? '▶ ' : ' '}</el.Text>
47 {graphCells(el, row.cells, width, `g:${c.sha}`)}
48 <el.Text> </el.Text>
49 <el.Text color={color} bold={showBranch} dimColor={!showBranch}>{fit(branchText, BRANCH_WIDTH)}</el.Text>
50 <el.Text> </el.Text>
51 {isHead && <el.Text bold color="inverseText" backgroundColor={color}>{here}</el.Text>}
52 {isHead && <el.Text> </el.Text>}
53 {badges.map((b, i) => (
54 <el.Text key={`b:${c.sha}:${i}`} color={b.kind === 'tag' ? 'warning' : b.kind === 'remote' ? 'suggestion' : undefined} dimColor={b.kind === 'remote'}>{`${badgeText(b)} `}</el.Text>
55 ))}
56 <el.Text bold={isSelected || merge !== null} inverse={isSelected} italic={merge !== null}>{cut(merge === null ? c.subject : `⇠ ${merge}`, room)}</el.Text>
57 </el.Button>
58 <el.Text dimColor>{right}</el.Text>
59 </el.Box>
60 )
61}
62
63/** A folded run: its branch's dotted line, how many commits, over which days. */
64const foldLine = (el: ElementTable, s: TreeView, from: number, to: number, width: number, act: Actions) => {
65 const first = s.commits[from]!
66 const last = s.commits[to]!
67 const row = s.rows[from]!
68 const color = laneColor(row.color)
69 const key = `fold:${first.sha}`
70 const isSelected = s.selected === key
71 const cells: Cell[] = row.cells.map((x, i) => (i === 2 * row.col ? { ch: '┇', color: x.color } : x))
72 const count = to - from + 1
73 const days = [dayLabel(last.at, s.now), dayLabel(first.at, s.now)].filter((d, i, all) => all.indexOf(d) === i).join(' → ')
74 const authors = [...new Set(s.commits.slice(from, to + 1).map(c => c.author))]
75 const who = cut(authors.length === 1 ? authors[0]! : `${authors.length} autori`, AUTHOR_MAX + 6)
76 const counted = `${count} commit`
77 const hint = isSelected ? ' o per aprirli' : ''
78 const room = s.columns - ROW_FIXED - width - counted.length - hint.length - who.length - 3
79 return (
80 <el.Box key={`row:${key}`} flexDirection="row" justifyContent="space-between">
81 <el.Button key={`sel:${key}`} label={`${count} commit`} plain onPress={() => act.select(key)}>
82 <el.Text color={color} bold>{isSelected ? '▶ ' : ' '}</el.Text>
83 {graphCells(el, cells, width, `g:${key}`)}
84 <el.Text> </el.Text>
85 <el.Text color={color}>{fit(s.owner.get(first.sha) ?? '', BRANCH_WIDTH)}</el.Text>
86 <el.Text> </el.Text>
87 <el.Text bold inverse={isSelected} color={isSelected ? undefined : color}>{counted}</el.Text>
88 <el.Text dimColor>{` ${cut(days, room - 2)}`}</el.Text>
89 <el.Text color="claude">{hint}</el.Text>
90 </el.Button>
91 <el.Text dimColor>{who}</el.Text>
92 </el.Box>
93 )
94}
95
96/** A day's heading, with the lanes going on through it. */
97const dayLine = (el: ElementTable, s: TreeView, item: Extract<Item, { kind: 'day' }>, width: number) => {
98 const above = item.index > 0 ? (s.rows[item.index - 1]?.below ?? []) : []
99 const label = `── ${dayLabel(item.at, s.now)} `
100 return (
101 <el.Text key={item.key}>
102 <el.Text>{' '}</el.Text>
103 {graphCells(el, above, width, `d:${item.key}`)}
104 <el.Text> </el.Text>
105 <el.Text bold color="claude">{label}</el.Text>
106 <el.Text dimColor>{'─'.repeat(Math.max(0, s.columns - 3 - width - label.length - 2))}</el.Text>
107 </el.Text>
108 )
109}
110
111/**
112 * The graph's width for each line: the widest of its day, so a quiet day is not
113 * pushed right by a busy one far below; within a day the texts line up.
114 */
115export const widthsOf = (items: readonly Item[], rows: readonly GraphRow[]): number[] => {
116 const cellsOf = (item: Item) =>
117 item.kind === 'day' ? (item.index > 0 ? (rows[item.index - 1]?.below.length ?? 0) : 0) : (rows[item.kind === 'fold' ? item.from : item.index]?.cells.length ?? 0)
118 const groups: number[] = []
119 let group = -1
120 for (const item of items) {
121 if (item.kind === 'day' || group < 0) group++
122 groups.push(group)
123 }
124 const widest = items.reduce<number[]>((acc, item, i) => {
125 const g = groups[i]!
126 const next = [...acc]
127 next[g] = Math.max(acc[g] ?? 0, cellsOf(item))
128 return next
129 }, [])
130 return groups.map(g => widest[g] ?? 0)
131}
132
133/** The map: the commits as a metro map, the selected one opened under its line. */
134export const commitMap = (el: ElementTable, s: TreeView, act: Actions) => {
135 const widths = widthsOf(s.items, s.rows).map(w => graphRoom(w, s.columns))
136 let lastBranch: string | undefined
137 const lines = s.items.flatMap((item, i) => {
138 const width = widths[i] ?? 0
139 if (item.kind === 'day') {
140 lastBranch = undefined
141 return [dayLine(el, s, item, width)]
142 }
143 if (item.kind === 'fold') {
144 lastBranch = s.owner.get(s.commits[item.from]!.sha)
145 return [foldLine(el, s, item.from, item.to, width, act)]
146 }
147 const c = s.commits[item.index]!
148 const row = s.rows[item.index]!
149 const branch = s.owner.get(c.sha)
150 const showBranch = branch !== lastBranch
151 lastBranch = branch
152 const line = commitLine(el, s, c, row, width, showBranch, act)
153 if (c.sha !== s.selected) return [line]
154 lastBranch = undefined
155 return [line, card(el, s, c, row, width, act)]
156 })
157 return (
158 <el.Box key="map" flexDirection="column">
159 <el.Box flexDirection="row" justifyContent="space-between">
160 <el.Text bold color="claude">▍Commit</el.Text>
161 <el.Box flexDirection="row" columnGap={2}>
162 <el.Button key="down" label="j giù" hotkey="j" plain dimColor onPress={() => act.move(1)} />
163 <el.Button key="up" label="k su" hotkey="k" plain dimColor onPress={() => act.move(-1)} />
164 <el.Button key="fold" label="o apri/chiudi" hotkey="o" plain dimColor onPress={() => act.toggleFold()} />
165 <el.Button key="head" label="h dove sei" hotkey="h" plain dimColor onPress={() => act.toHead()} />
166 </el.Box>
167 </el.Box>
168 {s.commits.length === 0 ? <el.Text dimColor>Nessun commit ancora.</el.Text> : lines}
169 {s.hasMore && <el.Button key="more" label="m mostra altri commit" hotkey="m" plain dimColor onPress={() => act.more()} />}
170 </el.Box>
171 )
172}
173hooks/paint.tsx 31 lines1import type { Color, ElementTable } from 'claude-code'
2
3import type { GraphRow } from './graph'
4
5/** The branches' colours: the one you are on first, then the others in the order the panorama shows them. */
6export const LANE_COLORS: readonly Color[] = ['claude', 'suggestion', 'success', 'merged', 'warning', 'planMode', 'ide', 'remember', 'autoAccept', 'permission']
7/** The graph takes at most this share of the pane; wider, its right end is cut. */
8const GRAPH_SHARE = 0.4
9const MIN_GRAPH = 7
10
11export const laneColor = (color: number): Color | undefined => (color < 0 ? undefined : LANE_COLORS[color % LANE_COLORS.length])
12
13/** The graph's room: all of it, or its share of the pane with the rest cut. */
14export const graphRoom = (width: number, columns: number) => Math.min(width, Math.max(MIN_GRAPH, Math.floor(columns * GRAPH_SHARE)))
15
16/** The graph's characters, each in its lane's colour, padded or cut to `width`: a cut one ends in `…`. */
17export const graphCells = (el: ElementTable, all: GraphRow['cells'], width: number, key: string) => [
18 ...(all.length <= width ? all : [...all.slice(0, width - 1), { ch: '…', color: -1 }]).map((c, i) => (
19 <el.Text key={`${key}:${i}`} color={laneColor(c.color)} bold>
20 {c.ch}
21 </el.Text>
22 )),
23 <el.Text key={`${key}:pad`}>{' '.repeat(Math.max(0, width - all.length))}</el.Text>,
24]
25
26/** A text cut to `room` characters, `…` at the cut; nothing when there is no room. */
27export const cut = (text: string, room: number) => (room <= 1 ? '' : text.length <= room ? text : `${text.slice(0, room - 1)}…`)
28
29/** A text padded or cut to exactly `width` characters. */
30export const fit = (text: string, width: number) => cut(text, width).padEnd(width)
31hooks/panorama.tsx 74 lines1import type { ElementTable } from 'claude-code'
2
3import type { Commit } from '../types'
4import type { BranchInfo } from './branches'
5import { cut, fit, laneColor } from './paint'
6import { axisOf, barOf, rankScale, spanOf, statusOf } from './timeline'
7import type { Actions, TreeView } from './view'
8
9/** Branches shown, one per digit key. */
10export const SHOWN_BRANCHES = 9
11const NAME_WIDTH = 22
12const NARROW_NAME_WIDTH = 16
13/** Below this many columns the names take less room. */
14const NARROW = 100
15const MIN_BAR = 16
16/** The room the status would like after the bar. */
17const STATUS_WIDTH = 42
18
19/** The columns of a branch's row: its name, its bar, what is left for its status. */
20export const panoramaColumns = (columns: number) => {
21 const name = columns < NARROW ? NARROW_NAME_WIDTH : NAME_WIDTH
22 const fixed = 3 + 2 + name + 1
23 const bar = Math.max(MIN_BAR, columns - fixed - STATUS_WIDTH)
24 return { name, fixed, bar, status: Math.max(0, columns - fixed - bar - 2) }
25}
26
27/** The panorama: one row per branch, its life drawn on a shared line of time. */
28export const panorama = (el: ElementTable, s: TreeView, act: Actions) => {
29 const bySha = new Map<string, Commit>(s.commits.map(c => [c.sha, c]))
30 const cols = panoramaColumns(s.columns)
31 const barWidth = cols.bar
32 const times = s.commits.map(c => c.at)
33 const pos = rankScale(times, barWidth)
34 const shown = s.infos.slice(0, SHOWN_BRANCHES)
35 const rest = s.infos.length - shown.length
36 return (
37 <el.Box key="panorama" flexDirection="column">
38 <el.Box flexDirection="row" justifyContent="space-between">
39 <el.Text bold color="claude">▍Rami</el.Text>
40 <el.Text dimColor>1–9 salta al ramo</el.Text>
41 </el.Box>
42 <el.Text dimColor>{`${' '.repeat(cols.fixed)}${axisOf(times, barWidth, s.now)}`}</el.Text>
43 {shown.map((b, i) => branchRow(el, b, i, bySha, pos, cols, s, act))}
44 {rest > 0 && <el.Text dimColor>{`${' '.repeat(5)}… e altri ${rest} rami, nella mappa qui sotto`}</el.Text>}
45 </el.Box>
46 )
47}
48
49const branchRow = (
50 el: ElementTable,
51 b: BranchInfo,
52 i: number,
53 bySha: ReadonlyMap<string, Commit>,
54 pos: (t: number) => number,
55 cols: ReturnType<typeof panoramaColumns>,
56 s: TreeView,
57 act: Actions,
58) => {
59 const span = spanOf(b, bySha)
60 const color = laneColor(b.color)
61 const isHere = b.status === 'head'
62 const isSelected = s.selected !== null && b.shas.includes(s.selected)
63 return (
64 <el.Button key={`jump:${b.name}`} label={b.name} hotkey={String(i + 1)} plain onPress={() => act.jump(b.name)}>
65 <el.Text dimColor>{` ${i + 1} `}</el.Text>
66 <el.Text color={color} bold>{isSelected ? '▶ ' : isHere ? '◉ ' : ' '}</el.Text>
67 <el.Text color={color} bold={isHere || isSelected} dimColor={b.status === 'merged' && !isSelected}>{fit(b.name, cols.name)}</el.Text>
68 <el.Text> </el.Text>
69 <el.Text color={color} bold>{barOf(b, pos(span.from), pos(span.to), cols.bar)}</el.Text>
70 <el.Text dimColor={!isHere} color={isHere ? color : undefined}>{` ${cut(statusOf(b, s.head.branch), cols.status)}`}</el.Text>
71 </el.Button>
72 )
73}
74hooks/card.tsx 197 lines1import type { ElementTable, RenderChildren, RenderSurface } from 'claude-code'
2
3import type { Commit, FileChange } from '../types'
4import type { GitAction } from './commands'
5import { switchActions } from './commands'
6import type { GraphRow } from './graph'
7import { fullDate, wrapText } from './labels'
8import type { Actions, TreeView } from './view'
9import { graphCells, laneColor } from './paint'
10
11const FILES_SHOWN = 8
12const BODY_LINES = 12
13/** The card's frame and padding, left and right. */
14const FRAME = 4
15const MIN_INNER = 10
16/** What a Button may draw besides its label: a hotkey's mark, its gap. */
17const BUTTON_EXTRA = 3
18const BUTTON_GAP = 2
19
20type Line = { key: string; el: RenderChildren }
21type CardButton = { key: string; label: string; hotkey?: string; onPress: (press: { surface: RenderSurface }) => unknown }
22
23/** A path cut from the left, so the file name stays. */
24const tailCut = (path: string, room: number) => (path.length <= room ? path : `…${path.slice(path.length - room + 1)}`)
25
26/** The message after its first line, without the blank lines around it. */
27export const bodyOf = (message: string) => message.split('\n').slice(1).join('\n').trim()
28
29/** Buttons in rows that fit `width`, by their labels' length. */
30export const packRows = <T extends { label: string }>(buttons: readonly T[], width: number): T[][] =>
31 buttons.reduce<{ rows: T[][]; used: number }>(
32 (acc, b) => {
33 const size = b.label.length + BUTTON_EXTRA
34 const last = acc.rows[acc.rows.length - 1]
35 if (last !== undefined && acc.used + BUTTON_GAP + size <= width) return { rows: [...acc.rows.slice(0, -1), [...last, b]], used: acc.used + BUTTON_GAP + size }
36 return { rows: [...acc.rows, [b]], used: size }
37 },
38 { rows: [], used: 0 },
39 ).rows
40
41const count = (n: number | null) => (n === null ? 'bin' : String(n))
42
43const fileLines = (el: ElementTable, files: readonly FileChange[], inner: number): Line[] => {
44 const added = files.reduce((n, f) => n + (f.added ?? 0), 0)
45 const removed = files.reduce((n, f) => n + (f.removed ?? 0), 0)
46 const summary: Line = {
47 key: 'files',
48 el: (
49 <el.Text key="files">
50 <el.Text bold>{`${files.length} file · `}</el.Text>
51 <el.Text color="success">{`+${added} `}</el.Text>
52 <el.Text color="error">{`−${removed}`}</el.Text>
53 </el.Text>
54 ),
55 }
56 const shown = files.slice(0, FILES_SHOWN).map((f, i) => {
57 const plus = `+${count(f.added)}`.padStart(6)
58 const minus = `−${count(f.removed)}`.padEnd(6)
59 return {
60 key: `file:${i}`,
61 el: (
62 <el.Text key={`file:${i}`}>
63 <el.Text color="success">{plus}</el.Text>
64 <el.Text color="error">{` ${minus} `}</el.Text>
65 <el.Text>{tailCut(f.path, inner - plus.length - minus.length - 2)}</el.Text>
66 </el.Text>
67 ),
68 }
69 })
70 const rest = files.length - FILES_SHOWN
71 return [summary, ...shown, ...(rest > 0 ? [{ key: 'more-files', el: <el.Text key="more-files" dimColor>{rest === 1 ? '… e un altro file' : `… e altri ${rest} file`}</el.Text> }] : [])]
72}
73
74const buttonRow = (el: ElementTable, key: string, buttons: readonly CardButton[]): Line => ({
75 key,
76 el: (
77 <el.Box key={key} flexDirection="row" columnGap={BUTTON_GAP}>
78 {buttons.map(b => (
79 <el.Button key={b.key} label={b.label} hotkey={b.hotkey} plain onPress={b.onPress} />
80 ))}
81 </el.Box>
82 ),
83})
84
85/** What can be done from the commit: go there, branch there, copy, ask. */
86const actionLines = (el: ElementTable, s: TreeView, c: Commit, inner: number, act: Actions): Line[] => {
87 if (s.pending !== null && s.pending.args[0] !== 'fetch') {
88 const command = `$ git ${s.pending.args.join(' ')}`
89 return [
90 ...wrapText(s.pending.explain, inner).map((t, i) => ({ key: `explain:${i}`, el: <el.Text key={`explain:${i}`} color="warning">{t}</el.Text> })),
91 { key: 'command', el: <el.Text key="command" dimColor>{command.length <= inner ? command : `${command.slice(0, inner - 1)}…`}</el.Text> },
92 {
93 key: 'answer',
94 el: (
95 <el.Box key="answer" flexDirection="row" columnGap={BUTTON_GAP}>
96 <el.Button key="confirm" label="Sì, esegui" hotkey="s" variant="primary" onPress={() => act.confirm()} />
97 <el.Button key="cancel" label="Annulla" hotkey="n" plain dimColor onPress={() => act.cancel()} />
98 </el.Box>
99 ),
100 },
101 ]
102 }
103 if (s.isNaming) {
104 return [
105 ...wrapText(`Nome del nuovo ramo, da ${c.sha.slice(0, 7)} (Invio per crearlo):`, inner).map((t, i) => ({
106 key: `name-label:${i}`,
107 el: <el.Text key={`name-label:${i}`} bold>{t}</el.Text>,
108 })),
109 {
110 key: 'name',
111 el:
112 'Input' in el ? (
113 <el.Input key="branch-name" placeholder="feature/…" submitLabel="Crea" autoFocus onSubmit={(value: string) => act.submitBranch(value.trim())} />
114 ) : (
115 <el.Text key="branch-name" dimColor>{`Qui non si può scrivere: git branch <nome> ${c.sha.slice(0, 7)}`}</el.Text>
116 ),
117 },
118 ...wrapText(s.nameProblem ?? ' ', inner).map((t, i) => ({
119 key: `name-problem:${i}`,
120 el: <el.Text key={`name-problem:${i}`} color="error">{t === '' ? ' ' : t}</el.Text>,
121 })),
122 buttonRow(el, 'name-cancel', [{ key: 'cancel-name', label: 'Annulla', onPress: () => act.cancel() }]),
123 ]
124 }
125 const switches: GitAction[] = switchActions(c, s.localNames, s.headSha, s.head.branch === null)
126 const goLines: Line[] =
127 switches.length > 0 && s.head.changed > 0
128 ? wrapText(`Per cambiare ramo, prima committa o metti da parte (git stash) ${s.head.changed === 1 ? 'il file modificato' : `i ${s.head.changed} file modificati`}.`, inner).map((t, i) => ({
129 key: `dirty:${i}`,
130 el: <el.Text key={`dirty:${i}`} color="warning">{t}</el.Text>,
131 }))
132 : packRows<CardButton>(
133 switches.map((a, i) => ({ key: `switch:${i}`, label: a.label, hotkey: i === 0 ? 'p' : undefined, onPress: () => act.ask(a) })),
134 inner,
135 ).map((r, i) => buttonRow(el, `switches:${i}`, r))
136 const tools = packRows<CardButton>(
137 [
138 { key: 'branch', label: '⑂ nuovo ramo qui', hotkey: 'b', onPress: () => act.startBranch() },
139 { key: 'copy', label: '⧉ copia hash', hotkey: 'y', onPress: press => act.copy(c.sha, press.surface) },
140 { key: 'explain', label: '✦ chiedi a Claude', hotkey: 'c', onPress: () => act.explain(c) },
141 ],
142 inner,
143 ).map((r, i) => buttonRow(el, `tools:${i}`, r))
144 return [...goLines, ...tools]
145}
146
147/** Every line of the card, each one row high, so the lanes beside it can match. */
148export const cardLines = (el: ElementTable, s: TreeView, c: Commit, inner: number, act: Actions): Line[] => {
149 const blank = (key: string): Line => ({ key, el: <el.Text key={key}> </el.Text> })
150 const meta = `${c.sha} · ${c.author} · ${fullDate(c.at)}`
151 const body = s.detail?.sha === c.sha ? wrapText(bodyOf(s.detail.body), inner) : []
152 const isBodyLong = body.length > BODY_LINES
153 return [
154 { key: 'meta', el: <el.Text key="meta" dimColor>{meta.length <= inner ? meta : `${meta.slice(0, inner - 1)}…`}</el.Text> },
155 blank('gap1'),
156 ...wrapText(c.subject, inner).map((t, i) => ({ key: `subject:${i}`, el: <el.Text key={`subject:${i}`} bold>{t === '' ? ' ' : t}</el.Text> })),
157 ...body.slice(0, BODY_LINES).map((t, i) => ({ key: `body:${i}`, el: <el.Text key={`body:${i}`}>{t === '' ? ' ' : t}</el.Text> })),
158 ...(isBodyLong ? [{ key: 'body-more', el: <el.Text key="body-more" dimColor>…</el.Text> }] : []),
159 blank('gap2'),
160 ...(s.detail?.sha === c.sha
161 ? fileLines(el, s.detail.files, inner)
162 : wrapText(s.isDetailFailed ? 'Non riesco a leggere il commit: r per riprovare.' : 'leggo il commit…', inner).map((t, i) => ({
163 key: `loading:${i}`,
164 el: <el.Text key={`loading:${i}`} color={s.isDetailFailed ? 'error' : undefined} dimColor={!s.isDetailFailed} italic>{t}</el.Text>,
165 }))),
166 blank('gap3'),
167 ...actionLines(el, s, c, inner, act),
168 ]
169}
170
171/**
172 * The selected commit opened under its row, framed in its lane's colour; the
173 * lanes going on below it are drawn beside the card, as tall as it is.
174 */
175export const card = (el: ElementTable, s: TreeView, c: Commit, row: GraphRow, width: number, act: Actions) => {
176 // `width` is the graph's as drawn: already within its share of the pane.
177 const besideWidth = 2 + width + 2
178 const inner = Math.max(MIN_INNER, s.columns - besideWidth - FRAME)
179 const lines = cardLines(el, s, c, inner, act)
180 const height = lines.length + 2
181 return (
182 <el.Box key="card" flexDirection="row">
183 <el.Box flexDirection="column" width={besideWidth}>
184 {Array.from({ length: height }, (_, i) => (
185 <el.Text key={`beside:${i}`}>
186 <el.Text>{' '}</el.Text>
187 {graphCells(el, row.below, width, `beside:${i}`)}
188 </el.Text>
189 ))}
190 </el.Box>
191 <el.Box flexDirection="column" borderStyle="round" borderColor={laneColor(row.color)} paddingX={1} width={inner + FRAME}>
192 {lines.map(l => l.el)}
193 </el.Box>
194 </el.Box>
195 )
196}
197