SLOPSHOPPER

git-tree

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

newpanecommandtoastprocesstimer
v0.1.0MITupdated 2026-10-12Wowbagger1994/claude-mods/mods/git-tree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-tree
│ ┃ Albero di git ✕ › fix the failing auth test and add an audit log call │ ┃ Leggo il repository… │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /git-tree │ ⎿ git-tree: Albero di git aperto: j e k scorrono i commit, p passa │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Albero di git
Leggo il repository…
README

git-tree

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.

Come si usa

/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:

  • «☁ allineato con GitHub»;
  • «↑ 2 da mandare», «↓ 3 da scaricare»;
  • «non collegato a GitHub»;
  • se non sei su nessun ramo (HEAD staccata), l'etichetta diventa «⎇ HEAD staccata su abc1234» in giallo e accanto c'è «nessun ramo: i commit fatti qui non hanno un nome».

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;
  • il ramo su cui sei finisce con ◉; 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:

  • «sei qui»;
  • «unito #3» (con il numero della pull request), più «in develop» se è entrato in un ramo diverso dal tuo, e «☁ resta su GitHub» se il ramo esiste ancora lì;
  • «aperto», con «☁ su GitHub» oppure «solo qui»;
  • «solo su GitHub».

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;
  • il nome del ramo compare dove la linea cambia ramo, e di nuovo dopo ogni intestazione di giorno e dopo la scheda; ☁ quando GitHub ha lo stesso commit, «☁ GitHub è qui» quando la copia su GitHub è rimasta indietro;
  • gli altri nomi su un commit compaiono prima del titolo: ⎇ ramo per un ramo locale, ☁ ramo per uno su GitHub, ◇ tag per un tag;
  • un merge si legge in parole: «⇠ unisce docs/convenzioni (#3)»;
  • un'intestazione segna l'inizio di ogni giorno più vecchio.

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:

  • l'hash intero, l'autore e la data (tagliati con … se la riga è stretta);
  • il titolo per intero, poi fino a 12 righe del resto del messaggio, seguite da … se ce ne sono altre;
  • i file cambiati con le righe aggiunte e tolte, fino a 8 file, poi «… e altri N file»;
  • i comandi.

Un clic su una riga la seleziona.

TastoCosa fa
j / kRiga successiva / precedente (commit o gruppo ripiegato)
oApre il gruppo selezionato; sopra uno dei suoi commit lo richiude
1–9Va all'ultimo commit del ramo con quel numero nel panorama
hTorna al commit su cui sei
pPassa al primo ramo o commit proposto nella scheda (chiede conferma); non c'è quando hai file non committati
bNuovo ramo sul commit selezionato: scrivi il nome e premi Invio
yCopia l'hash intero
cChiede a Claude di spiegare il commit
fScarica da GitHub (chiede conferma)
rRilegge il repository
mLegge altri 200 commit
s / nSì, esegui / Annulla, quando c'è una domanda aperta

Di che ramo è un commit

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:

  1. il ramo principale (main, master, develop o trunk), prima locale poi su GitHub;
  2. il ramo su cui sei;
  3. gli altri rami locali;
  4. i rami 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.

I comandi git

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.

PulsanteComandoQuando c'è
«⇄ passa a feature/x»git switch feature/xPer ogni ramo locale sul commit, tranne quello su cui sei
«⇄ passa a fix»git switch --create fix --track origin/fixPer 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 --allSempre; 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.

Cosa fa da sola

  • A fine risposta di Claude (non dei subagent), se il pannello è aperto, rilegge il repository: Claude può aver fatto un commit o cambiato ramo.

Cosa legge e cosa manda

  • Legge il repository con 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).
  • «Chiedi a Claude» manda nella conversazione solo l'hash corto e il percorso del repository, e chiede di spiegare il commit leggendolo con 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.
  • Lancia git con 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.
  • Non chiama modelli e non salva niente: tutto sta in memoria finché dura la sessione.
  • Mostra il nome dell'autore di ogni commit, non la sua email.

Requisiti

  • git 2.31 o più recente (per --diff-merges).
  • Per ↓ scarica, l'accesso al remoto già configurato in git.

Limiti

  • Mostra un repository solo: quello della cartella della sessione.
  • Il panorama mostra nove rami; gli altri sono comunque nella mappa.
  • L'appartenenza dei commit ai rami è ricostruita (vedi sopra): con una storia piena di merge fatti a mano, o di rami cancellati, un tratto può finire sotto un nome diverso da quello che ricordi.
  • Legge gli ultimi 200 commit; 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 ┗.
  • I rami del remoto origin sono mostrati senza origin/; quelli di altri remoti hanno il nome intero.
  • Il grafo prende la larghezza che serve al giorno più affollato tra due intestazioni, e al massimo il 40% del pannello: con moltissimi rami aperti nello stesso giorno le linee più a destra sono tagliate e la riga finisce con ….
  • Le linee accanto alla scheda hanno tante righe quante ne calcola la mod. Se una riga va a capo lo stesso (pannello molto stretto, caratteri larghi come emoji o ideogrammi nei titoli e nei percorsi), le linee finiscono una riga prima della scheda.
  • Se git non riesce a leggere un commit, la scheda lo dice; r riprova.
  • I caratteri di controllo nei titoli, negli autori, nei messaggi e nei percorsi sono mostrati come spazi.
  • Il nome di un nuovo ramo si scrive solo dove il pannello ha un campo di testo (terminale e desktop).
  • Ogni comando git ha 15 secondi, git fetch 2 minuti.

Installazione

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.

Sviluppo

Dalla cartella della mod:

claude plugin validate .
claude plugin test .

Oggi i test sono 62.

Source 14 files
hooks/register.tsx 355 lines
1import 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}
355
hooks/branches.ts 136 lines
1import 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}
136
hooks/commands.ts 70 lines
1import 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.`
70
hooks/fold.ts 69 lines
1import 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'
69
hooks/graph.ts 118 lines
1import 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)
118
hooks/log.ts 62 lines
1import 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
62
hooks/view.tsx 139 lines
1import 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)
139
hooks/labels.ts 107 lines
1import 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}
107
hooks/map.tsx 173 lines
1import 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}
173
hooks/paint.tsx 31 lines
1import 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)
31
hooks/panorama.tsx 74 lines
1import 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}
74
hooks/card.tsx 197 lines
1import 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