Panneau Git (/git) et bandeau au-dessus du prompt : branche, écarts avec le distant, modifications, branches (bascule, création), historique et pull request…

Panneau Git et bandeau au-dessus du prompt, dans Claude Code. Il montre la branche, son écart avec le distant, les fichiers modifiés, les branches, l'historique et la pull request.
git fetch et git switch.project (/project:commit, /project:sync, /project:push, /project:pr)./project:push apparaît grisé dans le prompt, Tab pour le prendre.Version actuelle : 0.4.3. Historique des versions : CHANGELOG.md.
Le mod est distribué par la marketplace mathis-plugins (dépôt GitHub matwi5/mathis-plugins, source dans plugins/git-board/). Installation d'un poste et protocole de développement : README à la racine du dépôt.
claude plugin marketplace update mathis-plugins
claude plugin install git-board@mathis-plugins
Pour une nouvelle version : modifier la source dans le clone de travail (plugins/git-board), monter version dans .claude-plugin/plugin.json et dans l'entrée du mod de .claude-plugin/marketplace.json à la racine du dépôt, commiter et pousser, puis :
claude plugin marketplace update mathis-plugins
claude plugin update git-board@mathis-plugins
Une mise à jour s'applique aux discussions démarrées après elle, ou après /reload-plugins.
Prérequis
git dans le PATH.project, pour les boutons Commit, Pull, Push et PR ;gh, pour lire la pull request et suivre la CI./git-board:git (ou /git) ouvre le panneau. L'ouverture relit tout : git fetch, puis le dépôt, puis la PR./git <dossier> suit un autre dépôt que celui de la session. Le bouton « ↩ session » de l'en-tête ramène au dossier de la session.Le panneau ne s'ouvre jamais seul : seulement par une commande ou par « ▸ /git » dans le bandeau.
De haut en bas :
/project en file ou en cours, « ↩ session » quand un autre dossier est suivi, « ↻ Relire ».dev, par exemple) repart à « PR à ouvrir » dès qu'elle a de nouveaux commits./project en file ou en cours, dernier message. C'est le seul endroit où une lecture s'affiche. Une erreur reste jusqu'à son « x ». Une commande /project est finie dès que le dépôt relu montre son effet (« … : fait d'après le dépôt. ») : commit, HEAD a bougé ; sync, un fetch depuis le lancement et plus rien à récupérer ; push, plus rien à pousser ; PR, une PR existe. Sinon, la fin du tour de Claude la clôt, sans « ✓ » (« tour terminé » ou « interrompue »)./project:version (numéro de version et CHANGELOG), avec un bouton « ▶ D'abord · /project:version » ; sans repère dans le dépôt, cette commande n'est finie qu'avec le tour de Claude. En HEAD détachée, les tiroirs Pull, Push et PR disent seulement de basculer sur une branche.+a −r lignes et barre de diff, puis les fichiers groupés (En conflit, Indexés, Modifiés, Nouveaux).Les listes longues sont plafonnées (20 fichiers, 12 branches, 12 commits) avec « ▸ tout voir (N) ».
Le retour des actions passe par un toast et par la zone d'activité.
Mobile. Le mobile n'a ni champ de saisie ni liste de choix. Le panneau s'y dessine quand même et dit en une ligne atténuée ce qui n'est pas disponible (indice de commit, création de branche, choix de la base de PR…).
Une ligne, deux au plus, dans un dépôt git seulement.
⎇ feat/export ↑2 ↓1 ● 3 + 1 · +13 −1 ⇄ #7 Ouverte ✓ 1 ◌ 1 fetch il y a 4 min ▸ /git x
▶ /project:commit en file
+a −r (séparé des fichiers par « · ») et PR./project en file (« ▶ ») ou en cours (spinner), sinon bascule en cours.◌ CI, ◌ CI 1/3, ✓ CI, ✗ CI <check>) ; sans PR, il prend sa place, à partir de 72 colonnes./git show le réaffiche.Le bandeau s'empile avec ceux des autres mods (flow-board, asana-board…) : il ne les efface jamais. Les sources du moteur ne fixent pas l'ordre vertical entre plugins. En tête, une barre « ▍ » et le nom du mod, de sa couleur, alignés avec ceux des autres bandeaux.
| Commande | Effet |
|---|---|
/git-board:git | Ouvre le panneau et relit tout. Connue dès le premier message. |
/git | La même commande, déclarée au démarrage du mod. Masquée du menu, utilisable tapée en entier. |
/git changes, /git branches, /git history | Ouvre le panneau sur cet onglet (modifications et historique marchent aussi). |
/git hide | Masque le bandeau ; la ligne d'état prend le relais. |
/git show | Réaffiche le bandeau et ouvre le panneau. |
/git <dossier> | Suit le dépôt de ce dossier. |
hide, show et les noms d'onglets sont réservés : un dossier qui porte ce nom s'ouvre avec son chemin (./history).
Dès le premier message : /git-board:git. Sur le desktop, le mod ne démarre qu'à l'envoi du premier message : avant, /git n'existe pas. /git-board:git, déclarée par commands/git.md, est là tout de suite. Le mod y répond lui-même, sans passer par le modèle.
| Donnée | Emplacement | Portée |
|---|---|---|
| Dépôt lu, PR, onglet, tiroir, formulaires, bandeau masqué, listes dépliées | État de session ($.state) | Une discussion |
Le mod n'écrit rien sur disque.
| Lecture | Quand |
|---|---|
Lecture locale (sans fetch), puis PR (un appel gh) | Au démarrage, en arrière-plan, une seule fois, si le dépôt n'est pas déjà lu |
| PR | À la demande, ou quand la branche a changé |
| Lecture complète (fetch, dépôt, PR) | /git, « ↻ Relire », changement de dossier |
| Lecture du dépôt | 500 ms après le dernier outil d'écriture de Claude (avec la PR après git push ou gh pr) ; à la fin d'une commande /project lancée du panneau |
Coup d'œil (status + diff --numstat) | Toutes les 10 s ; relecture seulement si quelque chose a bougé |
Checks du commit poussé (un appel gh) | Après un push, toutes les 25 s jusqu'à la fin des checks (voir ci-dessous) |
Il n'y a pas de fetch périodique : l'âge du fetch est affiché dans l'en-tête et dans le bandeau large. Il vient du dernier fetch de la session ou, s'il est plus récent, de la date de .git/FETCH_HEAD (un fetch fait hors de la session compte donc aussi).
Les âges affichés se redessinent toutes les 5 s pendant une activité, toutes les 30 s au repos.
git push lancé par Claude (outil Bash), et après un /project:push lancé du panneau, une fois le dépôt relu : seulement sur un distant GitHub, et si le commit est bien arrivé sur l'amont (plus rien à pousser).gh pr view (ses checks se mettent aussi à jour dans le panneau ; tant que la PR montre encore l'ancienne tête, c'est « en attente ») ; sans PR, gh run list --commit <sha> --json status,conclusion,name,url (exécutions GitHub Actions).concurrency: cancel-in-progress : il ne compte pas ; seul, il reste un échec. Sur la route, l'étape Checks dit « attente » au lieu de « aucun ». Sans suivi, une PR sans checks et une PR dont les checks ne sont pas encore lancés s'affichent pareil (« aucun » sur la route) : seul le suivi les distingue.gh absent, non connecté ou illisible : le suivi s'arrête sans rien afficher, le panneau reste comme avant.À la fin d'un tour principal (pas celui d'un sous-agent), après une relecture du dépôt : si la branche a des commits en avance sur son amont, que l'arbre est propre (aucun fichier modifié, nouveau ou en conflit), qu'aucune opération (rebase, merge…) n'est en cours, que le dépôt suivi est celui de la session et qu'aucune commande /project n'est en file ou en cours, /project:push est proposé en suggestion grisée dans le prompt ($.prompt.suggest). Le moteur ne la montre pas si le prompt contient déjà du texte. flow-board l'écarte pendant un parcours.
git-board/
├── .claude-plugin/plugin.json manifeste (nom, version, contrat de types)
├── .claude-plugin/types/ types du moteur (copiés d'asana-board), pour tsconfig.json
├── commands/git.md /git-board:git, connue avant le démarrage du mod
├── hooks/
│ ├── hooks.json déclare le module register.tsx
│ ├── register.tsx branchement : événements, état, lectures git et gh, commande /git
│ ├── model.ts données pures : palette, analyse des sorties git et gh, étape suggérée
│ ├── view.tsx dessin du panneau et du bandeau, toutes surfaces, sans `$`
│ └── svg.ts SVG copiés de flow-board, plus la jauge ↑/↓
├── types/index.d.ts contrat de l'état (`PluginState`)
├── tsconfig.json
└── tests/git-board.test.tsx 60 tests `claude plugin test`
Tous les appels au moteur ($) restent dans register.tsx : claude plugin validate lit les clés d'état et les appels d'après ce fichier.
La table d'éléments de $.ui.resolve(e) contient tous les éléments sur toutes les surfaces : un élément absent y dessine un fragment vide. register.tsx lit donc les capacités de la surface dans le contrat du moteur (pas de Svg sur le terminal, ni Input ni Select sur le mobile) et les passe à kitOf.
cd ~
claude plugin validate plugins/git-board
claude plugin test plugins/git-board
~) : depuis un dossier au chemin trop long, Windows refuse le dossier courant.fakeRepo). Le mock repère la sous-commande après color.ui=never : ne pas changer l'ordre des options de git().terminal, desktop, vscode et mobile, à 40, 80 et 140 colonnes, et le bandeau sur terminal et desktop, à 30, 80, 120 et 160 colonnes et de part et d'autre de chaque palier (55/56, 63/64, 71/72, 89/90).process.run, session.cwd…) doit rendre { value }.~/.claude/mods-archives/git-board-<version> sur le PC Windows.FETCH_HEAD n'en donnent ; un dépôt tout juste cloné n'a pas encore de FETCH_HEAD).$.process.run ne lance pas les hooks git : c'est pour ça que commit, pull, push et PR passent par /project:*.gh run list) : les checks d'autres services n'apparaissent qu'avec une PR.hooks/register.tsx 786 lines1// Panneau Git (/git) et bandeau au-dessus du prompt : la branche, ses écarts avec le
2// distant, les fichiers modifiés, les branches (bascule, création), l'historique et la
3// pull request.
4//
5// - Ce module est le seul à parler au moteur (`$`) : état, hooks, lectures git et gh.
6// Le dessin est dans view.tsx, les données pures dans model.ts, les SVG dans svg.ts.
7// - Le panneau ne lance lui-même que des lectures git, `git fetch` et `git switch`
8// (sans les hooks du dépôt : `$.process.run` les coupe pour git).
9// - Commit, pull, push et PR passent par les commandes du plugin project
10// (/project:commit, /project:sync, /project:push, /project:pr) : le bouton les lance
11// comme si on les tapait ; si le moteur refuse, la commande est mise dans le prompt.
12// - Le bandeau résume la branche dans un dépôt ; masqué, la ligne d'état prend le relais.
13// - `/git-board:git` (commands/git.md) est connue dès le premier message ; `/git`, déclarée
14// au démarrage, sort du menu mais reste utilisable tapée en entier.
15// - Après un push, la CI du commit poussé est suivie jusqu'à ce que ses checks soient finis.
16// - À la fin d'un tour, des commits à pousser sur un arbre propre : `/project:push` est
17// proposé en suggestion grisée dans le prompt.
18
19import { atom, read, update } from 'claude-code'
20import type { Register } from 'claude-code'
21
22import type { Action, CiWatch, Expanded, Form, GitFile, GitRepo, Meta, Notice, PrChecks, PrState, Running, Switching, Tab } from '../types'
23import {
24 CI_EVERY_MS,
25 LOG_FORMAT,
26 MAX_COMMITS,
27 PR_FIELDS,
28 REF_FORMAT,
29 RUN_FIELDS,
30 ciPhaseOf,
31 isCiActive,
32 plural,
33 prOfBranch,
34 runsOf,
35 commandFor,
36 defaultOf,
37 firstLine,
38 gitError,
39 githubUrl,
40 localOf,
41 parseLog,
42 parseNumstat,
43 parseRefs,
44 parseRemoteUrls,
45 parseStatus,
46 prOf,
47 sameDir,
48 isRunDone,
49 slash,
50 VERSION_COMMAND,
51 statusText,
52 tabOfArg,
53 type LoadOptions,
54 type Run,
55} from './model.ts'
56import { drawBand, drawPane, kitOf, type Actions, type Caps, type View } from './view.tsx'
57
58const PANE = 'git'
59const PANE_TITLE = 'Git'
60/** La commande déclarée par `commands/git.md`, préfixée du nom du plugin. */
61const STATIC_GIT = 'git-board:git'
62/** La suggestion grisée de fin de tour (texte exact, que flow-board reconnaît). */
63const PUSH_SUGGESTION = '/project:push'
64const ARG_HINT = '[changes|branches|history|hide|show|dossier du dépôt]'
65const TAB_LABEL: Record<Tab, string> = { changes: 'Modifications', branches: 'Branches', history: 'Historique' }
66
67const EMPTY_META: Meta = { folder: null, sessionRoot: null, loading: null, error: null, fetchedAt: null, fetchError: null }
68const EMPTY_FORM: Form = { hint: '', pullFrom: 'upstream', prBase: '', prDraft: false, prAuto: false, filter: '', newBranch: '' }
69const EMPTY_EXPANDED: Expanded = { files: false, branches: false, remotes: false, commits: false }
70
71// Gardés par l'hôte : survivent au rechargement à chaud du module.
72const repo = atom({ plugin: 'git-board', key: 'repo' } as const, null as GitRepo | null)
73const prState = atom({ plugin: 'git-board', key: 'pr' } as const, null as PrState)
74const meta = atom({ plugin: 'git-board', key: 'meta' } as const, EMPTY_META)
75const tab = atom({ plugin: 'git-board', key: 'tab' } as const, 'changes' as Tab)
76const action = atom({ plugin: 'git-board', key: 'action' } as const, null as Action | null)
77const form = atom({ plugin: 'git-board', key: 'form' } as const, EMPTY_FORM)
78const notice = atom({ plugin: 'git-board', key: 'notice' } as const, null as Notice)
79const busy = atom({ plugin: 'git-board', key: 'busy' } as const, null as string | null)
80const running = atom({ plugin: 'git-board', key: 'running' } as const, null as Running)
81const switching = atom({ plugin: 'git-board', key: 'switching' } as const, null as Switching)
82const showRemotes = atom({ plugin: 'git-board', key: 'showRemotes' } as const, false)
83// Bandeau masqué : le temps de la session, comme flow-board.
84const isBandHidden = atom({ plugin: 'git-board', key: 'isBandHidden' } as const, false)
85const expanded = atom({ plugin: 'git-board', key: 'expanded' } as const, EMPTY_EXPANDED)
86const ci = atom({ plugin: 'git-board', key: 'ci' } as const, null as CiWatch)
87
88// Les éléments que chaque surface dessine, d'après le contrat `Elements` du moteur : pas de
89// `Svg` sur le terminal, ni `Input` ni `Select` sur le mobile. La table de `$.ui.resolve`
90// les contient quand même (fragments vides) : on ne peut pas s'y fier.
91const capsOf = (surface: string): Caps => ({ hasSvg: surface !== 'terminal', hasFields: surface !== 'mobile' })
92
93// Outils après lesquels l'état git a pu changer.
94const WRITERS = new Set(['Bash', 'PowerShell', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
95
96// État du module, perdu au rechargement : rien que des optimisations.
97let inFlight: Promise<void> | null = null
98let queued: LoadOptions | null = null
99let lastPeek = ''
100let timer: { cancel: () => void } | null = null
101let wantPr = false
102let wantCi = false
103let ticks = 0
104// Suivi de la CI : la minuterie des relectures, et une relecture à la fois.
105let ciTimer: { cancel: () => void } | null = null
106let isCiPolling = false
107
108/** Un effet d'interface raté (pas d'écran, surface sans panneau) ne doit jamais gêner la lecture. */
109const quietly = async (effect: () => unknown) => {
110 try {
111 await effect()
112 } catch {
113 // Rien à afficher ici : l'état, lui, est déjà écrit.
114 }
115}
116
117// ─── Commande /git et /git-board:git ────────────────────────────────────────
118
119// `/git` n'existe qu'après le démarrage du mod ; `/git-board:git`, déclarée par
120// `commands/git.md`, est connue dès le premier message. Les deux reçoivent la même réponse,
121// sans passer par le modèle. Aucun état posé par `session.start` n'est supposé ici.
122const answerGit = async ($: any, e: { args: string }) => {
123 const arg = e.args.trim()
124 const word = arg.toLowerCase()
125 if (word === 'hide') {
126 await setBandHidden($, true)
127 return { text: 'Bandeau Git masqué (/git show pour le réafficher).' }
128 }
129 const tabArg = tabOfArg(word)
130 let text = 'Panneau Git ouvert.'
131 if (word === 'show') {
132 await setBandHidden($, false)
133 text = 'Bandeau Git réaffiché, panneau Git ouvert.'
134 } else if (tabArg) {
135 await update($, tab, () => tabArg)
136 text = `Panneau Git ouvert (onglet ${TAB_LABEL[tabArg]}).`
137 } else if (arg) {
138 await update($, meta, (m) => ({ ...m, folder: arg }))
139 text = `Panneau Git ouvert sur ${arg}.`
140 }
141 await quietly(() => $.ui.open({ id: PANE, title: PANE_TITLE }))
142 // Par une minuterie : la relecture (fetch compris) survit à la fin de la commande.
143 $.clock.after(0, () => void refresh($, { fetch: true, pr: true, say: true }))
144 return { text }
145}
146
147// `/git` reste utilisable tapée en entier, mais sort du menu : `/git-board:git`, déjà là
148// avant le premier message, y suffit et le doublon disparaît.
149const hideDuplicate = async ($: any, e: any, next: any) => ({ ...(await next(e)), isHidden: true })
150
151export const register: Register = (on) => {
152 on('session.start', async ($, e, next) => {
153 const result = await next(e)
154 // Un enregistrement refusé ne doit pas empêcher la minuterie ni la première lecture.
155 await quietly(() =>
156 $.command.register({
157 name: 'git',
158 description: 'Panneau Git : modifications, branches, historique, commit, pull, push et PR',
159 argumentHint: ARG_HINT,
160 }),
161 )
162 // Toutes les 5 s : un coup d'œil léger une fois sur deux (10 s) pour les changements faits
163 // hors de la session, et le redessin des âges affichés.
164 $.clock.every(5_000, () => void tick($))
165 // Des données dès le début, en arrière-plan : une seule lecture locale (sans fetch). Le
166 // dépôt est écrit (le bandeau s'affiche) avant la lecture de la PR, un seul appel gh.
167 if (!(await read($, repo))) $.clock.after(0, () => void refresh($, { pr: true }))
168 return result
169 })
170
171 on('command.run', { command: 'git' }, answerGit)
172 on('command.describe', { command: 'git' }, hideDuplicate)
173 on('command.run', { command: STATIC_GIT }, answerGit)
174
175 // Claude modifie des fichiers ou lance git : le panneau se relit juste après.
176 on('tool.call', async ($, e, next) => {
177 const result = await next(e)
178 if (WRITERS.has(e.tool)) {
179 const command = (e as { command?: unknown }).command
180 const text = typeof command === 'string' ? command : ''
181 // Après un `git push`, la CI du commit poussé est suivie (une fois le dépôt relu).
182 schedule($, /\bgit\s+push\b|\bgh\s+pr\b/.test(text), /\bgit\s+push\b/.test(text))
183 }
184 return result
185 })
186
187 // Une commande /project lancée du panneau est un tour de Claude : elle commence au
188 // prochain tour (un tour déjà en cours finit d'abord) et se termine avec lui.
189 on('turn.start', async ($, e, next) => {
190 // Le tour d'un sous-agent du tour en cours ne fait pas démarrer la commande en attente.
191 if (e.agentId !== undefined) return next(e)
192 const r = await read($, running)
193 if (r && !r.isStarted) await update($, running, (x) => (x ? { ...x, isStarted: true } : x))
194 return next(e)
195 })
196
197 on('turn.complete', async ($, e, next) => {
198 const result = await next(e)
199 const r = await read($, running)
200 if (!e.agentId && r?.isStarted) {
201 await update($, running, () => null)
202 // Pas de ✓ : un tour fini n'est pas une réussite, le résultat se lit dans la relecture.
203 if (e.isAborted) await say($, 'info', `${r.command} interrompue : relecture du dépôt.`)
204 else await say($, 'ok', `${r.command} : tour terminé, relecture du dépôt.`)
205 // Les outils du tour ont déjà relu le dépôt ; reste la pull request, et la CI après
206 // un /project:push.
207 schedule($, true, r.action === 'push')
208 }
209 // Fin d'un tour principal : la commande suivante en suggestion grisée, dépôt relu.
210 if (!e.agentId) $.clock.after(0, () => void suggestPush($))
211 return result
212 })
213
214 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
215 const view = await readView($)
216 const kit = kitOf($.ui.resolve(e) as unknown as Record<string, unknown>, e.props.bodyColumns, capsOf(e.surface))
217 return drawPane(kit, view, actionsFor($)) as any
218 })
219
220 // Le bandeau se compose avec ceux des autres plugins (flow-board, asana-board…) : le leur
221 // reste au-dessus, celui de Git dessous ; jamais seul quand un autre bandeau existe.
222 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
223 if (e.props.hasSurvey) return next(e)
224 // Lire AVANT tout retour anticipé : la lecture abonne le bandeau aux changements.
225 const view = await readView($)
226 if (!view.repo || view.isBandHidden) return next(e)
227 const below = await next(e)
228 const kit = kitOf($.ui.resolve(e) as unknown as Record<string, unknown>, e.props.bodyColumns, capsOf(e.surface))
229 const own = drawBand(kit, view, actionsFor($)) as any
230 if (!own) return below
231 if (below.type === 'engine') return own
232 const { Box } = kit
233 return (
234 <Box flexDirection="column" width={e.props.bodyColumns}>
235 {below}
236 {own}
237 </Box>
238 )
239 })
240}
241
242// ─── Vue et actions ─────────────────────────────────────────────────────────
243
244async function readView($: any): Promise<View> {
245 return {
246 repo: await read($, repo),
247 pr: await read($, prState),
248 meta: await read($, meta),
249 tab: await read($, tab),
250 action: await read($, action),
251 form: await read($, form),
252 notice: await read($, notice),
253 busy: await read($, busy),
254 running: await read($, running),
255 switching: await read($, switching),
256 showRemotes: await read($, showRemotes),
257 expanded: await read($, expanded),
258 isBandHidden: await read($, isBandHidden),
259 ci: await read($, ci),
260 now: await $.clock.now(),
261 }
262}
263
264function actionsFor($: any): Actions {
265 return {
266 setTab: (t) => update($, tab, () => t),
267 toggleAction: (a) => update($, action, (x) => (x === a ? null : a)),
268 closeAction: () => update($, action, () => null),
269 setForm: (patch) => update($, form, (x) => ({ ...x, ...patch })),
270 refresh: async () => {
271 // Une commande restée « en file » sans jamais ouvrir de tour : ↻ l'oublie.
272 await update($, running, (x) => (x && !x.isStarted ? null : x))
273 await refresh($, { fetch: true, pr: true, say: true })
274 },
275 launch: (a) => launch($, a),
276 launchVersion: () => launchVersion($),
277 openFolder: (value) => openFolder($, value),
278 backToSession: () => openFolder($, ''),
279 askSwitch: (name, isRemote) => askSwitch($, name, isRemote),
280 confirmSwitch: (name, isRemote) => switchBranch($, name, isRemote),
281 cancelSwitch: () => update($, switching, () => null),
282 createBranch: (value) => createBranch($, value),
283 toggleRemotes: () => update($, showRemotes, (v) => !v),
284 toggleExpanded: (list) => update($, expanded, (x) => ({ ...x, [list]: !x[list] })),
285 dismissNotice: () => update($, notice, () => null),
286 hideBand: async () => {
287 await setBandHidden($, true)
288 await quietly(() => $.ui.toast('Bandeau Git masqué : /git show pour le réafficher'))
289 },
290 openPane: () => quietly(() => $.ui.open({ id: PANE, title: PANE_TITLE })),
291 }
292}
293
294async function setBandHidden($: any, hidden: boolean) {
295 await update($, isBandHidden, () => hidden)
296 await syncStatus($)
297}
298
299/** La ligne d'état sous le prompt ne sert que de relais quand le bandeau est masqué. */
300async function syncStatus($: any) {
301 const r = await read($, repo)
302 const hidden = await read($, isBandHidden)
303 const text = r && hidden ? statusText(r) : undefined
304 await quietly(() => $.ui.status(text))
305}
306
307/** Redessine pour faire avancer les âges : toutes les 5 s en activité, toutes les 30 s au repos. */
308async function tick($: any) {
309 ticks += 1
310 if (ticks % 2 === 0) await peek($)
311 // Un rechargement du module perd la minuterie de la CI, pas son suivi : on la relance.
312 if (!ciTimer && isCiActive(await read($, ci))) ciTimer = $.clock.every(CI_EVERY_MS, () => void pollCi($))
313 const m = await read($, meta)
314 const isBusy = Boolean(m.loading) || (await read($, busy)) !== null || (await read($, running)) !== null
315 if (isBusy || ticks % 6 === 0) await quietly(() => $.ui.invalidate('ui.render'))
316}
317
318// ─── Lecture du dépôt ───────────────────────────────────────────────────────
319
320// Lectures sans verrou optionnel : le coup d'œil périodique ne doit jamais bloquer un
321// `git commit` lancé au même moment (index.lock). Jamais d'invite d'identifiants.
322// Ne rejette jamais : git introuvable ou trop lent se lit comme un échec (code -1),
323// pour qu'une commande en échec n'emporte pas toute la lecture du dépôt.
324async function git($: any, cwd: string, args: string[], timeoutMs = 20_000): Promise<Run> {
325 try {
326 return await $.process.run(['git', '--no-optional-locks', '-c', 'core.quotepath=off', '-c', 'color.ui=never', ...args], {
327 cwd,
328 timeoutMs,
329 env: { GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' },
330 })
331 } catch (err: any) {
332 return { exitCode: -1, stdout: '', stderr: String(err?.message ?? err) }
333 }
334}
335
336// Une relecture à la fois ; celles demandées entre-temps sont fusionnées en une seule.
337function refresh($: any, opts: LoadOptions): Promise<void> {
338 if (inFlight) {
339 queued = { fetch: queued?.fetch || opts.fetch, pr: queued?.pr || opts.pr, say: queued?.say || opts.say }
340 return inFlight
341 }
342 inFlight = (async () => {
343 try {
344 await load($, opts)
345 await settleRun($)
346 } catch (err: any) {
347 await setMeta($, { loading: null })
348 if (opts.say) await say($, 'error', String(err?.message ?? err))
349 } finally {
350 inFlight = null
351 }
352 const again = queued
353 queued = null
354 if (again) await refresh($, again)
355 })()
356 return inFlight
357}
358
359async function load($: any, opts: LoadOptions) {
360 const m = await read($, meta)
361 const cwd: string = await $.session.cwd()
362 const folder = m.folder ?? cwd
363 // Seule une relecture demandée (say) montre son avancement : chaque écriture redessine.
364 const step = async (loading: string | null) => {
365 if (opts.say) await setMeta($, { loading })
366 }
367
368 await step('Lecture du dépôt…')
369 const top = await git($, folder, ['rev-parse', '--show-toplevel'])
370 if (top.exitCode !== 0) {
371 await putRepo($, null)
372 await putPr($, null)
373 if (await read($, ci)) await stopCi($)
374 await setMeta($, { loading: null, error: top.exitCode === -1 ? `git ne répond pas : ${firstLine(top.stderr)}` : `${folder} n'est pas dans un dépôt git.` })
375 await syncStatus($)
376 return
377 }
378 const root = top.stdout.trim()
379
380 if (opts.fetch) {
381 await step('Récupération du distant (git fetch)…')
382 const fetched = await git($, root, ['fetch', '--prune', '--quiet'], 60_000)
383 if (fetched.exitCode === 0) await setMeta($, { fetchedAt: await $.clock.now(), fetchError: null })
384 else {
385 // Sans distant, rien à récupérer : ce n'est pas une erreur.
386 const remotes = await git($, root, ['remote'])
387 if (remotes.stdout.trim()) {
388 await setMeta($, { fetchError: firstLine(fetched.stderr) || 'git fetch a échoué' })
389 if (opts.say) await say($, 'error', `git fetch : ${firstLine(fetched.stderr) || 'échec'}`)
390 }
391 }
392 await step('Lecture du dépôt…')
393 }
394
395 const [status, numstat, refs, log, unpushed, urls, originHead, gitDir, sessionTop] = await Promise.all([
396 git($, root, ['status', '--porcelain=v2', '--branch', '--show-stash', '-z']),
397 git($, root, ['diff', 'HEAD', '--numstat', '-z']),
398 git($, root, ['for-each-ref', '--sort=-committerdate', `--format=${REF_FORMAT}`, 'refs/heads', 'refs/remotes']),
399 git($, root, ['log', '-n', String(MAX_COMMITS), `--format=${LOG_FORMAT}`]),
400 git($, root, ['rev-list', '-n', '500', 'HEAD', '--not', '--remotes']),
401 git($, root, ['config', '--get-regexp', '^remote\\..*\\.url$']),
402 git($, root, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']),
403 git($, root, ['rev-parse', '--absolute-git-dir']),
404 sameDir(folder, cwd) ? Promise.resolve(top) : git($, cwd, ['rev-parse', '--show-toplevel']),
405 ])
406 if (status.exitCode !== 0) throw new Error(`git status : ${firstLine(status.stderr) || 'échec'}`)
407 lastPeek = status.stdout + numstat.stdout
408
409 // L'âge du dernier fetch, même fait hors de la session : la date de `FETCH_HEAD`. Un
410 // fetch plus récent de la session l'emporte.
411 if (gitDir.exitCode === 0) {
412 const fetchHead = await $.fs.stat(`${gitDir.stdout.trim()}/FETCH_HEAD`).catch(() => null)
413 const mtime = typeof fetchHead?.mtimeMs === 'number' ? fetchHead.mtimeMs : 0
414 if (mtime > 0 && mtime > ((await read($, meta)).fetchedAt ?? 0)) await setMeta($, { fetchedAt: mtime })
415 }
416
417 const st = parseStatus(status.stdout)
418 const stats = numstat.exitCode === 0 ? parseNumstat(numstat.stdout) : new Map<string, [number | null, number | null]>()
419 const files: GitFile[] = st.files.map((x) => {
420 const s = x.kind === 'untracked' ? undefined : stats.get(x.path)
421 return { ...x, added: s ? s[0] : null, removed: s ? s[1] : null }
422 })
423 const { locals, remotes, remoteNames } = parseRefs(refs.exitCode === 0 ? refs.stdout : '')
424 const remoteUrls = parseRemoteUrls(urls.exitCode === 0 ? urls.stdout : '')
425 const hasRemote = remoteUrls.size > 0
426 const notPushed = new Set(unpushed.exitCode === 0 ? unpushed.stdout.split('\n').filter(Boolean) : [])
427 const url = remoteUrls.get('origin') ?? [...remoteUrls.values()][0] ?? null
428
429 const next: GitRepo = {
430 root,
431 name: root.split(/[\\/]/).filter(Boolean).pop() ?? root,
432 branch: st.branch,
433 head: st.oid ? st.oid.slice(0, 7) : null,
434 oid: st.oid,
435 upstream: st.upstream,
436 ahead: st.ahead,
437 behind: st.behind,
438 defaultBranch: defaultOf(originHead, locals, remoteNames),
439 webUrl: url ? githubUrl(url) : null,
440 hasRemote,
441 operation: gitDir.exitCode === 0 ? await operationOf($, gitDir.stdout.trim()) : null,
442 stash: st.stash,
443 unpushed: notPushed.size,
444 files,
445 branches: locals,
446 remotes,
447 commits: log.exitCode === 0 ? parseLog(log.stdout, notPushed, hasRemote) : [],
448 }
449 await putRepo($, next)
450 await setMeta($, { error: null, sessionRoot: sessionTop.exitCode === 0 ? sessionTop.stdout.trim() : null })
451 // Changement de branche ou de dossier : le suivi de la CI s'arrête.
452 const watched = await read($, ci)
453 if (watched && (watched.root !== root || watched.branch !== next.branch)) await stopCi($)
454 await syncStatus($)
455
456 // La pull request : à la demande, ou quand la branche a changé depuis la dernière lecture.
457 const known = await read($, prState)
458 if (opts.pr || (known !== null && known.branch !== (next.branch ?? ''))) {
459 await step('Lecture de la pull request (gh)…')
460 await putPr($, await loadPr($, next))
461 }
462 await step(null)
463}
464
465// Coup d'œil périodique : deux commandes légères, une relecture seulement si l'état a bougé.
466async function peek($: any) {
467 if (inFlight) return
468 const r = await read($, repo)
469 if (!r) return
470 try {
471 const [s, n] = await Promise.all([
472 git($, r.root, ['status', '--porcelain=v2', '--branch', '--show-stash', '-z']),
473 git($, r.root, ['diff', 'HEAD', '--numstat', '-z']),
474 ])
475 if (s.stdout + n.stdout !== lastPeek) await refresh($, {})
476 } catch {
477 // Dépôt déplacé ou git introuvable : la prochaine relecture complète le dira.
478 }
479}
480
481// Après un outil de Claude : une relecture groupée, une demi-seconde après le dernier.
482// `watch` : un push vient d'avoir lieu, la CI est suivie une fois le dépôt relu.
483function schedule($: any, pr: boolean, watch = false) {
484 wantPr = wantPr || pr
485 wantCi = wantCi || watch
486 timer?.cancel()
487 timer = $.clock.after(500, () => {
488 timer = null
489 void runScheduled($)
490 })
491}
492
493async function runScheduled($: any) {
494 const withPr = wantPr
495 const withCi = wantCi
496 wantPr = false
497 wantCi = false
498 await refresh($, { pr: withPr })
499 if (withCi) await startCi($)
500}
501
502/** La relecture groupée en attente part tout de suite ; sinon on attend celle en cours. */
503async function flush($: any) {
504 if (timer) {
505 timer.cancel()
506 timer = null
507 await runScheduled($)
508 } else if (inFlight) await inFlight
509}
510
511// ─── Suggestion de fin de tour ──────────────────────────────────────────────
512
513// Des commits en avance sur l'amont, un arbre propre et aucune commande /project en file ni
514// en cours : /project:push en suggestion grisée. Dans le dossier de la session seulement,
515// là où agit la commande ; jamais pendant une opération (rebase, merge…).
516async function suggestPush($: any) {
517 await flush($)
518 const r = await read($, repo)
519 if (!r || !r.branch || !r.upstream || r.ahead === 0 || r.files.length > 0 || r.operation) return
520 const m = await read($, meta)
521 if (m.sessionRoot === null || !sameDir(m.sessionRoot, r.root)) return
522 if ((await read($, running)) !== null) return
523 await quietly(() => $.prompt.suggest({ text: PUSH_SUGGESTION }))
524}
525
526// ─── Suivi de la CI après un push ───────────────────────────────────────────
527
528const NO_CHECKS: PrChecks = { passed: 0, failed: 0, pending: 0, firstFailed: null }
529
530// Un push a eu lieu (dépôt relu) : la CI du commit poussé est suivie, relue toutes les 25 s
531// jusqu'à ce que tous ses checks soient finis. Seulement sur GitHub, une fois le commit
532// arrivé sur l'amont ; un commit déjà suivi (ou fini) ne repart pas.
533async function startCi($: any) {
534 const r = await read($, repo)
535 if (!r || !r.branch || !r.upstream || r.ahead > 0 || !r.webUrl || !r.oid) return
536 const w = await read($, ci)
537 if (w && w.sha === r.oid && w.root === r.root && w.branch === r.branch) return
538 const next: CiWatch = { root: r.root, branch: r.branch, sha: r.oid, startedAt: await $.clock.now(), phase: 'waiting', checks: NO_CHECKS }
539 await update($, ci, () => next)
540 ciTimer?.cancel()
541 ciTimer = $.clock.every(CI_EVERY_MS, () => void pollCi($))
542 await pollCi($)
543}
544
545// Une relecture des checks : par la PR ouverte quand il y en a une (ses checks se mettent à
546// jour dans le panneau), sinon par les exécutions GitHub Actions du commit. gh absent, non
547// connecté ou illisible : le suivi s'arrête sans rien dire, le panneau reste comme avant.
548async function pollCi($: any) {
549 if (isCiPolling) return
550 const w = await read($, ci)
551 if (!w || !isCiActive(w)) return stopCiTimer()
552 isCiPolling = true
553 try {
554 const r = await read($, repo)
555 if (!r || r.root !== w.root || r.branch !== w.branch) return await stopCi($)
556 let checks: PrChecks | null = null
557 const known = prOfBranch(r, await read($, prState))
558 if (known && known.state === 'OPEN') {
559 const p = await loadPr($, r)
560 if (!p || p.error) return await stopCi($)
561 await putPr($, p)
562 // Juste après le push, la PR peut encore montrer l'ancien commit et ses checks.
563 if (p.pr) checks = p.pr.head && p.pr.head !== w.sha ? NO_CHECKS : p.pr.checks
564 }
565 if (!checks) {
566 const res: Run = await $.process.run(['gh', 'run', 'list', '--commit', w.sha, '--json', RUN_FIELDS, '--limit', '50'], {
567 cwd: r.root,
568 timeoutMs: 20_000,
569 env: { GH_PROMPT_DISABLED: '1', NO_COLOR: '1' },
570 })
571 if (res.exitCode !== 0) return await stopCi($)
572 checks = runsOf(res.stdout)
573 }
574 const phase = ciPhaseOf(checks, (await $.clock.now()) - w.startedAt)
575 const next: CiWatch = { ...w, phase, checks }
576 // Un nouveau push a pu remplacer le suivi pendant la lecture : celle-ci est alors périmée.
577 const current = await read($, ci)
578 if (!current || current.sha !== w.sha || current.root !== w.root || current.branch !== w.branch) return
579 if (JSON.stringify(next) !== JSON.stringify(current)) await update($, ci, (cur) => (cur && cur.sha === w.sha ? next : cur))
580 if (!isCiActive(next)) {
581 stopCiTimer()
582 await announceCi($, next)
583 }
584 } catch {
585 await stopCi($)
586 } finally {
587 isCiPolling = false
588 }
589}
590
591async function announceCi($: any, w: NonNullable<CiWatch>) {
592 const c = w.checks
593 const total = c.passed + c.failed + c.pending
594 if (w.phase === 'passed') await say($, 'ok', `✓ CI réussie sur ${w.branch} (${plural(total, 'check')}).`)
595 else if (w.phase === 'failed') await say($, 'error', `✗ CI échouée sur ${w.branch} : ${c.firstFailed ?? `${plural(c.failed, 'check')} en échec`}.`)
596 else if (w.phase === 'timeout') await say($, 'info', `CI encore en cours sur ${w.branch} après 20 min : suivi arrêté (↻ relit la PR).`)
597}
598
599function stopCiTimer() {
600 ciTimer?.cancel()
601 ciTimer = null
602}
603
604async function stopCi($: any) {
605 stopCiTimer()
606 await update($, ci, () => null)
607}
608
609async function operationOf($: any, gitDir: string) {
610 const has = (name: string) =>
611 $.fs.stat(`${gitDir}/${name}`).then(
612 () => true,
613 () => false,
614 )
615 if ((await has('rebase-merge')) || (await has('rebase-apply'))) return 'rebase'
616 if (await has('MERGE_HEAD')) return 'merge'
617 if (await has('CHERRY_PICK_HEAD')) return 'cherry-pick'
618 if (await has('REVERT_HEAD')) return 'revert'
619 return null
620}
621
622async function loadPr($: any, r: GitRepo): Promise<PrState> {
623 const branch = r.branch ?? ''
624 if (!r.branch || !r.webUrl || r.branch === r.defaultBranch) return { branch, pr: null, error: null }
625 let res: Run
626 try {
627 res = await $.process.run(['gh', 'pr', 'view', r.branch, '--json', PR_FIELDS], {
628 cwd: r.root,
629 timeoutMs: 20_000,
630 env: { GH_PROMPT_DISABLED: '1', NO_COLOR: '1' },
631 })
632 } catch {
633 return { branch, pr: null, error: 'GitHub CLI (gh) introuvable : la pull request ne peut pas être lue.' }
634 }
635 if (res.exitCode !== 0) {
636 if (/no (open )?pull requests? found/i.test(res.stderr)) return { branch, pr: null, error: null }
637 return { branch, pr: null, error: `gh : ${firstLine(res.stderr) || 'lecture de la PR impossible'}` }
638 }
639 // Un JSON illisible lève ici : `refresh` le relève, le dépôt est déjà écrit.
640 return prOf(branch, res.stdout)
641}
642
643// ─── Actions ────────────────────────────────────────────────────────────────
644
645async function openFolder($: any, value: string) {
646 await update($, meta, (m) => ({ ...m, folder: value.trim() || null }))
647 await refresh($, { fetch: true, pr: true, say: true })
648}
649
650// Avec des modifications suivies, la bascule demande confirmation : elles suivent la
651// branche, et git refuse si l'une entre en conflit.
652async function askSwitch($: any, name: string, isRemote: boolean) {
653 const r = await read($, repo)
654 if (!r) return
655 if (r.files.some((x) => x.kind !== 'untracked')) await update($, switching, () => ({ name, isRemote }))
656 else await switchBranch($, name, isRemote)
657}
658
659async function switchBranch($: any, name: string, isRemote: boolean) {
660 const r = await read($, repo)
661 if (!r) return
662 const target = isRemote ? localOf(name) : name
663 await update($, switching, () => null)
664 await update($, busy, () => `Bascule vers ${target}…`)
665 let isDone = false
666 try {
667 // Une branche seulement distante : `--track` crée la branche locale qui la suit.
668 const res = await git($, r.root, isRemote ? ['switch', '--track', name] : ['switch', name], 60_000)
669 if (res.exitCode === 0) isDone = true
670 else await say($, 'error', gitError(res))
671 } catch (err: any) {
672 await say($, 'error', String(err?.message ?? err))
673 } finally {
674 await update($, busy, () => null)
675 }
676 if (isDone) {
677 await say($, 'ok', `✓ Sur la branche ${target}`)
678 await refresh($, { pr: true })
679 }
680}
681
682async function createBranch($: any, value: string) {
683 const name = value.trim()
684 const r = await read($, repo)
685 if (!r || !name) return
686 if (r.branches.some((b) => b.name === name)) return say($, 'error', `La branche ${name} existe déjà : bascule dessus depuis la liste.`)
687 await update($, busy, () => `Création de ${name}…`)
688 let isDone = false
689 try {
690 const check = await git($, r.root, ['check-ref-format', '--branch', name])
691 if (check.exitCode !== 0) await say($, 'error', `« ${name} » n'est pas un nom de branche valide.`)
692 else {
693 const res = await git($, r.root, ['switch', '-c', name], 60_000)
694 if (res.exitCode === 0) isDone = true
695 else await say($, 'error', gitError(res))
696 }
697 } catch (err: any) {
698 await say($, 'error', String(err?.message ?? err))
699 } finally {
700 await update($, busy, () => null)
701 }
702 if (isDone) {
703 await update($, form, (x) => ({ ...x, newBranch: '' }))
704 await say($, 'ok', r.files.length ? `✓ Branche ${name} créée : tes modifications l'ont suivie.` : `✓ Branche ${name} créée.`)
705 await refresh($, { pr: true })
706 }
707}
708
709// Le bouton lance la commande comme si on la tapait : elle part dès que la session est
710// libre. Introuvable ou refusée, elle est mise dans le prompt, prête à envoyer.
711async function launch($: any, a: Action) {
712 const now = await $.clock.now()
713 const r = await read($, repo)
714 const f = await read($, form)
715 if (!r) return
716 const c = commandFor(a, r, f)
717 const text = slash(c)
718 await update($, action, () => null)
719 if (a === 'commit') await update($, form, (x) => ({ ...x, hint: '' }))
720 const list: { name: string; plugin?: string }[] = await $.command.list().catch(() => [])
721 const hit = list.find((x) => x.name === `project:${c.name}`)
722 if (!hit) return toPrompt($, text, 'commande introuvable : le plugin project est-il activé ?')
723 await update($, running, () => ({ command: text, isStarted: false, action: a, head: r.head, startedAt: now }))
724 await quietly(() => $.ui.toast(`${text} lancée : elle part dès que Claude est libre`))
725 $.command.run({ command: hit.name, args: c.args }).catch(async (err: any) => {
726 await update($, running, () => null)
727 await toPrompt($, text, String(err?.message ?? err))
728 })
729}
730
731// /project:version, suggérée dans le tiroir Push : lancée comme les autres, mais sans repère
732// dans le dépôt, elle n'est finie qu'avec le tour de Claude.
733async function launchVersion($: any) {
734 const now = await $.clock.now()
735 const list: { name: string; plugin?: string }[] = await $.command.list().catch(() => [])
736 const hit = list.find((x) => x.name === 'project:version')
737 if (!hit) return toPrompt($, VERSION_COMMAND, 'commande introuvable : le plugin project est-il activé ?')
738 await update($, running, () => ({ command: VERSION_COMMAND, isStarted: false, startedAt: now }))
739 await quietly(() => $.ui.toast(`${VERSION_COMMAND} lancée : elle part dès que Claude est libre`))
740 $.command.run({ command: hit.name, args: '' }).catch(async (err: any) => {
741 await update($, running, () => null)
742 await toPrompt($, VERSION_COMMAND, String(err?.message ?? err))
743 })
744}
745
746async function toPrompt($: any, text: string, why: string) {
747 const filled = await $.prompt.fill({ text, mode: 'replace' }).catch(() => null)
748 await say($, 'info', filled?.isFilled ? `${text} est dans le prompt : Entrée pour le lancer (${why}).` : `Tape ${text} dans le prompt (${why}).`)
749}
750
751// ─── Petits outils ──────────────────────────────────────────────────────────
752
753// Un mot dans la zone d'activité, doublé d'un toast ; les confirmations s'effacent seules,
754// les erreurs restent jusqu'au « x ».
755async function say($: any, tone: 'ok' | 'error' | 'info', text: string) {
756 await update($, notice, () => ({ text, tone }))
757 await quietly(() => $.ui.toast(text))
758 if (tone !== 'error') $.clock.after(8000, () => void update($, notice, (x) => (x && x.text === text ? null : x)))
759}
760
761// Une commande /project dont le dépôt montre l'effet est finie, même si le tour de Claude
762// continue sur autre chose : on ne la dit plus « en cours ».
763async function settleRun($: any) {
764 const run = await read($, running)
765 const r = await read($, repo)
766 if (!run || !r || !isRunDone(run, r, await read($, meta), await read($, prState))) return
767 await update($, running, () => null)
768 await say($, 'ok', `${run.command} : fait d'après le dépôt.`)
769 // Hors de la relecture en cours (qui appelle cette fonction) : le suivi de la CI du push.
770 if (run.action === 'push') $.clock.after(0, () => void startCi($))
771}
772
773async function setMeta($: any, patch: Partial<Meta>) {
774 const m = await read($, meta)
775 if (Object.entries(patch).some(([k, v]) => (m as any)[k] !== v)) await update($, meta, (x) => ({ ...x, ...patch }))
776}
777
778// N'écrivent que ce qui a changé : chaque écriture redessine le panneau et le bandeau.
779async function putRepo($: any, value: GitRepo | null) {
780 if (JSON.stringify(await read($, repo)) !== JSON.stringify(value)) await update($, repo, () => value)
781}
782
783async function putPr($: any, value: PrState) {
784 if (JSON.stringify(await read($, prState)) !== JSON.stringify(value)) await update($, prState, () => value)
785}
786hooks/model.ts 461 lines1// Données pures du panneau Git : palette, analyse des sorties git et gh, étape suggérée,
2// libellés. Aucun appel au moteur ici : tout ce qui touche `$` vit dans register.tsx.
3
4import type { Action, CiWatch, Form, GitBranch, GitCommit, GitFile, GitRemoteBranch, GitRepo, Meta, PrChecks, PrState, PullRequest, Running } from '../types'
5
6// Couleurs brutes (hex) mi-tons, copiées de flow-board (hooks/catalog.ts) : lisibles en thème
7// clair comme sombre, sur le terminal comme sur le desktop.
8export const C = {
9 violet: '#9B7BEA',
10 lilac: '#B79BF5',
11 blue: '#4A9EE8',
12 cyan: '#2EB8C9',
13 teal: '#3FB68B',
14 tangerine: '#E8913A',
15 coral: '#EF7D57',
16 amber: '#D9A520',
17 green: '#4CB782',
18 red: '#E5534B',
19 pink: '#E06C9F',
20 magenta: '#C36BD9',
21 indigo: '#7C83F2',
22 sky: '#56B4E9',
23 sand: '#C9A66B',
24 slate: '#7F8EA3',
25 gray: '#8B8B93',
26 /** Texte foncé posé sur un aplat de couleur. */
27 ink: '#17171C',
28} as const
29
30// L'app desktop fond les espaces ordinaires : les écarts dans un texte sont insécables.
31// NB contient une espace insécable U+00A0 littérale (invisible) : ne pas la remplacer.
32export const NB = ' '
33export const SEP = `${NB}${NB}·${NB}${NB}`
34
35// Rôles figés de la palette : cyan pour la sync et le distant (pull, push).
36export const ACTION_COLOR: Record<Action, string> = { commit: C.green, pull: C.cyan, push: C.cyan, pr: C.violet }
37export const OPERATION: Record<string, string> = { rebase: 'Rebase', merge: 'Merge', 'cherry-pick': 'Cherry-pick', revert: 'Revert' }
38export const MAX_FILES = 80
39export const MAX_COMMITS = 25
40
41export const REF_FORMAT = '%(refname)%00%(HEAD)%00%(upstream:short)%00%(upstream:track,nobracket)%00%(committerdate:unix)%00%(subject)%00%(worktreepath)'
42export const LOG_FORMAT = '%H%x00%h%x00%an%x00%ct%x00%D%x00%s'
43export const PR_FIELDS = 'number,title,url,state,isDraft,baseRefName,headRefOid,reviewDecision,statusCheckRollup'
44
45export type Run = { exitCode: number; stdout: string; stderr: string }
46export type LoadOptions = { fetch?: boolean; pr?: boolean; say?: boolean }
47
48type StatusFile = Omit<GitFile, 'added' | 'removed'>
49
50// `git status --porcelain=v2 --branch --show-stash -z` : en-têtes `# …`, puis une entrée
51// par fichier (1 ordinaire, 2 renommé suivi de l'ancien chemin, u conflit, ? non suivi).
52export function parseStatus(out: string) {
53 const s = { oid: null as string | null, branch: null as string | null, upstream: null as string | null, ahead: 0, behind: 0, stash: 0, files: [] as StatusFile[] }
54 const parts = out.split('\0')
55 for (let i = 0; i < parts.length; i++) {
56 const rec = parts[i] ?? ''
57 if (!rec) continue
58 if (rec.startsWith('# ')) {
59 const [key, ...rest] = rec.slice(2).split(' ')
60 const value = rest.join(' ')
61 if (key === 'branch.oid') s.oid = value === '(initial)' ? null : value
62 else if (key === 'branch.head') s.branch = value === '(detached)' ? null : value
63 else if (key === 'branch.upstream') s.upstream = value
64 else if (key === 'branch.ab') {
65 const ab = value.match(/\+(\d+) -(\d+)/)
66 if (ab) {
67 s.ahead = Number(ab[1])
68 s.behind = Number(ab[2])
69 }
70 } else if (key === 'stash') s.stash = Number(value) || 0
71 continue
72 }
73 const f = rec.split(' ')
74 const xy = f[1] ?? '..'
75 const index = xy[0] ?? '.'
76 const work = xy[1] ?? '.'
77 if (rec[0] === '1') s.files.push({ path: f.slice(8).join(' '), from: null, index, work, kind: 'changed' })
78 else if (rec[0] === '2') s.files.push({ path: f.slice(9).join(' '), from: parts[++i] ?? null, index, work, kind: 'changed' })
79 else if (rec[0] === 'u') s.files.push({ path: f.slice(10).join(' '), from: null, index, work, kind: 'conflict' })
80 else if (rec[0] === '?') s.files.push({ path: rec.slice(2), from: null, index: '?', work: '?', kind: 'untracked' })
81 }
82 return s
83}
84
85// `git diff HEAD --numstat -z` : « ajouts\tretraits\tchemin », ou pour un renommage
86// « ajouts\tretraits\t » suivi de l'ancien puis du nouveau chemin ; `-` pour un binaire.
87export function parseNumstat(out: string) {
88 const map = new Map<string, [number | null, number | null]>()
89 const parts = out.split('\0')
90 for (let i = 0; i < parts.length; i++) {
91 const rec = parts[i]
92 if (!rec) continue
93 const [a, d, p] = rec.split('\t')
94 let path = p
95 if (p === '') {
96 i += 2
97 path = parts[i]
98 }
99 if (!path) continue
100 map.set(path, [a === '-' || a === undefined ? null : Number(a), d === '-' || d === undefined ? null : Number(d)])
101 }
102 return map
103}
104
105export function parseRefs(out: string) {
106 const locals: GitBranch[] = []
107 const all: GitRemoteBranch[] = []
108 for (const row of out.split('\n')) {
109 if (!row) continue
110 const [ref = '', head = '', upstream = '', track = '', date = '0', subject = '', worktree = ''] = row.split('\0')
111 if (ref.startsWith('refs/heads/')) {
112 locals.push({
113 name: ref.slice('refs/heads/'.length),
114 isCurrent: head === '*',
115 upstream: upstream || null,
116 ahead: Number(track.match(/ahead (\d+)/)?.[1] ?? 0),
117 behind: Number(track.match(/behind (\d+)/)?.[1] ?? 0),
118 isGone: track === 'gone',
119 worktree: head !== '*' && worktree ? worktree : null,
120 date: Number(date) * 1000,
121 subject,
122 })
123 } else if (ref.startsWith('refs/remotes/') && !ref.endsWith('/HEAD')) {
124 const name = ref.slice('refs/remotes/'.length)
125 all.push({ name, local: localOf(name), date: Number(date) * 1000, subject })
126 }
127 }
128 // La branche courante d'abord, puis les plus récentes.
129 locals.sort((a, b) => Number(b.isCurrent) - Number(a.isCurrent))
130 const names = new Set(locals.map((b) => b.name))
131 const followed = new Set(locals.map((b) => b.upstream))
132 return {
133 locals,
134 remotes: all.filter((b) => !names.has(b.local) && !followed.has(b.name)),
135 remoteNames: new Set(all.map((b) => b.name)),
136 }
137}
138
139export function parseLog(out: string, notPushed: Set<string>, hasRemote: boolean): GitCommit[] {
140 return out
141 .split('\n')
142 .filter(Boolean)
143 .map((row) => {
144 const [hash = '', short = '', author = '', date = '0', refs = '', subject = ''] = row.split('\0')
145 return { hash, short, author, date: Number(date) * 1000, refs, subject, isPushed: hasRemote && !notPushed.has(hash) }
146 })
147}
148
149// « remote.origin.url https://… » → origin ↦ https://…
150export function parseRemoteUrls(out: string) {
151 const map = new Map<string, string>()
152 for (const row of out.split('\n')) {
153 const hit = row.trim().match(/^remote\.(.+)\.url\s+(.+)$/)
154 if (hit && hit[1] && hit[2]) map.set(hit[1], hit[2])
155 }
156 return map
157}
158
159// Branche par défaut, dans l'ordre de reference/git.md du plugin project : origin/HEAD,
160// sinon origin/main ou origin/master, sinon main ou master en local.
161export function defaultOf(originHead: Run, locals: GitBranch[], remoteNames: Set<string>) {
162 if (originHead.exitCode === 0 && originHead.stdout.trim()) return originHead.stdout.trim().replace(/^origin\//, '')
163 for (const name of ['main', 'master']) if (remoteNames.has(`origin/${name}`)) return name
164 for (const name of ['main', 'master']) if (locals.some((b) => b.name === name)) return name
165 return null
166}
167
168// Adresse web d'un distant GitHub (https, ssh ou scp), sans identifiants.
169export function githubUrl(url: string) {
170 const hit = url.trim().match(/^(?:(?:https?|ssh):\/\/)?(?:[^@/]+@)?github\.com[:/](.+?)(?:\.git)?\/?$/i)
171 return hit && hit[1] && /^[\w.-]+\/[\w.-]+$/.test(hit[1]) ? `https://github.com/${hit[1]}` : null
172}
173
174// Les vérifications GitHub : CheckRun (status, conclusion) et StatusContext (state).
175// Le nom du premier check en échec est gardé pour le suivi de la CI. Un check annulé dont un
176// homonyme (même workflow, même nom) n'est pas annulé a été remplacé, par exemple par
177// `concurrency: cancel-in-progress` : il ne compte pas.
178export function checksOf(list: any[]): PrChecks {
179 const out: PrChecks = { passed: 0, failed: 0, pending: 0, firstFailed: null }
180 const keyOf = (c: any) => `${c.workflowName ?? ''}\u0000${c.name || c.context || ''}`
181 const isCancelled = (c: any) => String(c.conclusion || c.state || '').toUpperCase() === 'CANCELLED'
182 const live = new Set(list.filter((c) => !isCancelled(c)).map(keyOf))
183 for (const c of list) {
184 if (isCancelled(c) && live.has(keyOf(c))) continue
185 const verdict = String(c.conclusion || c.state || '').toUpperCase()
186 if (c.status && String(c.status).toUpperCase() !== 'COMPLETED') out.pending += 1
187 else if (['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(verdict)) out.passed += 1
188 else if (['', 'PENDING', 'EXPECTED'].includes(verdict)) out.pending += 1
189 else {
190 out.failed += 1
191 if (!out.firstFailed) out.firstFailed = String(c.name || c.context || c.workflowName || '') || null
192 }
193 }
194 return out
195}
196
197// ─── Suivi de la CI après un push ───────────────────────────────────────────
198
199/** Relecture des checks pendant le suivi. */
200export const CI_EVERY_MS = 25_000
201/** Plafond du suivi : au-delà, il s'arrête sur « CI encore en cours ». */
202export const CI_CAP_MS = 20 * 60_000
203/** Toujours aucun check au bout de ce délai : le dépôt n'a pas de CI pour ce commit. */
204export const CI_WAIT_MS = 3 * 60_000
205export const RUN_FIELDS = 'status,conclusion,name,url'
206
207/**
208 * `gh run list --commit <sha> --json status,conclusion,name,url` : les exécutions GitHub
209 * Actions du commit, au même format de comptes que les checks d'une PR (minuscules ici).
210 * Lève une erreur sur un JSON illisible.
211 */
212export function runsOf(stdout: string): PrChecks {
213 const list = JSON.parse(stdout)
214 return checksOf(Array.isArray(list) ? list : [])
215}
216
217/** Où en est la CI, d'après les checks lus et le temps écoulé depuis le push. */
218export function ciPhaseOf(c: PrChecks, elapsedMs: number): NonNullable<CiWatch>['phase'] {
219 const total = c.passed + c.failed + c.pending
220 if (total === 0) return elapsedMs >= CI_WAIT_MS ? 'none' : 'waiting'
221 if (c.pending > 0) return elapsedMs >= CI_CAP_MS ? 'timeout' : 'running'
222 return c.failed > 0 ? 'failed' : 'passed'
223}
224
225export function isCiActive(w: CiWatch) {
226 return w !== null && (w.phase === 'waiting' || w.phase === 'running')
227}
228
229/** Le suivi de la CI à afficher : seulement celui de la branche et du commit en tête. */
230export function ciOf(r: GitRepo, w: CiWatch): NonNullable<CiWatch> | null {
231 if (!w || w.root !== r.root || w.branch !== r.branch || (r.oid !== null && r.oid !== w.sha)) return null
232 return w
233}
234
235/**
236 * L'état de la CI en un mot : en attente (rien encore lancé), en cours (finis/total),
237 * réussie, échouée (premier check en échec), ou aucun check. `isShort` pour le bandeau.
238 */
239export function ciText(w: NonNullable<CiWatch>, isShort = false): [string, string] {
240 const c = w.checks
241 const total = c.passed + c.failed + c.pending
242 if (w.phase === 'waiting') return [isShort ? `◌${NB}CI` : `◌${NB}CI${NB}en${NB}attente`, C.amber]
243 if (w.phase === 'running') return [`◌${NB}CI${NB}${total - c.pending}/${total}`, C.amber]
244 if (w.phase === 'timeout') return [isShort ? `◌${NB}CI${NB}?` : `◌${NB}CI${NB}encore${NB}en${NB}cours${NB}(suivi${NB}arrêté)`, C.amber]
245 if (w.phase === 'none') return [`aucun${NB}check`, C.gray]
246 if (w.phase === 'failed') {
247 const name = c.firstFailed ? `${isShort ? NB : `${NB}:${NB}`}${c.firstFailed}` : ''
248 return [`✗${NB}CI${isShort ? '' : `${NB}échouée`}${name}`, C.red]
249 }
250 return [isShort ? `✓${NB}CI` : `✓${NB}CI${NB}réussie`, C.green]
251}
252
253/**
254 * La réponse JSON de `gh pr view` ; lève une erreur sur un JSON illisible ou incomplet
255 * (relevée par `refresh`, après l'écriture du dépôt). Sans numéro ni adresse, la PR ne
256 * peut pas se dessiner : un lien sans `href` ferait refuser tout le panneau.
257 */
258export function prOf(branch: string, stdout: string): PrState {
259 const d = JSON.parse(stdout)
260 if (typeof d?.number !== 'number' || typeof d?.url !== 'string' || !/^https:\/\//.test(d.url)) {
261 throw new Error('gh : réponse incomplète (numéro ou adresse de la PR absents).')
262 }
263 return {
264 branch,
265 pr: {
266 number: d.number,
267 title: d.title ?? '',
268 url: d.url,
269 state: d.state,
270 isDraft: Boolean(d.isDraft),
271 base: d.baseRefName ?? '',
272 head: typeof d.headRefOid === 'string' && d.headRefOid ? d.headRefOid : null,
273 review: d.reviewDecision || null,
274 checks: checksOf(d.statusCheckRollup ?? []),
275 },
276 error: null,
277 }
278}
279
280export function commandFor(a: Action, r: GitRepo, f: Form) {
281 if (a === 'commit') return { name: 'commit', args: f.hint.trim() }
282 if (a === 'pull') return { name: 'sync', args: f.pullFrom === 'base' && r.defaultBranch && r.branch && r.branch !== r.defaultBranch ? 'base' : '' }
283 if (a === 'push') return { name: 'push', args: '' }
284 // La base choisie, si elle existe encore ; la branche par défaut se passe d'argument.
285 const base = f.prBase && f.prBase !== r.defaultBranch && f.prBase !== r.branch && r.branches.some((b) => b.name === f.prBase) ? f.prBase : ''
286 return { name: 'pr', args: [base, f.prDraft ? '--draft' : '', f.prAuto ? '--auto' : ''].filter(Boolean).join(' ') }
287}
288
289/** Suggérée avant le push : le numéro de version et le CHANGELOG partent avec les commits. */
290export const VERSION_COMMAND = '/project:version'
291
292export function slash(c: { name: string; args: string }) {
293 return `/project:${c.name}${c.args ? ` ${c.args}` : ''}`
294}
295
296/** L'étape logique suivante ; rien pendant une opération ou un conflit. */
297export function suggest(r: GitRepo, p: PrState): Action | null {
298 if (r.operation || r.files.some((x) => x.kind === 'conflict')) return null
299 if (r.files.length) return 'commit'
300 if (r.behind) return 'pull'
301 if (r.hasRemote && (r.ahead || (!r.upstream && r.branch && r.branch !== r.defaultBranch))) return 'push'
302 if (r.webUrl && r.branch && r.branch !== r.defaultBranch && r.upstream && p && p.branch === r.branch && !p.error && !prOfBranch(r, p)) return 'pr'
303 return null
304}
305
306/** La ligne d'état (relais du bandeau masqué). */
307export function statusText(r: GitRepo) {
308 const parts = [`⎇ ${branchLabel(r)}`]
309 if (r.ahead) parts.push(`↑${r.ahead}`)
310 if (r.behind) parts.push(`↓${r.behind}`)
311 const text = parts.join(' ')
312 return r.files.length ? `${text} · ${r.files.length} modif.` : text
313}
314
315export function branchLabel(r: GitRepo) {
316 return r.branch ?? `HEAD détachée (${r.head ?? '?'})`
317}
318
319/** Comptes des fichiers : conflits, suivis modifiés, non suivis, et le total des lignes. */
320export function countsOf(r: GitRepo) {
321 const conflicts = r.files.filter((x) => x.kind === 'conflict').length
322 const fresh = r.files.filter((x) => x.kind === 'untracked').length
323 const added = r.files.reduce((n, x) => n + (x.added ?? 0), 0)
324 const removed = r.files.reduce((n, x) => n + (x.removed ?? 0), 0)
325 return { conflicts, fresh, changed: r.files.length - conflicts - fresh, added, removed }
326}
327
328/**
329 * La PR de la branche courante seulement. `gh pr view <branche>` rend la dernière PR de la
330 * branche, même fusionnée ou fermée : une branche réutilisée après une fusion (dev, par
331 * exemple) garderait l'ancienne PR et sa route toute faite. Une PR fusionnée ou fermée ne
332 * compte que tant que HEAD est encore sa tête ; au-delà, les nouveaux commits n'en ont pas.
333 */
334export function prOfBranch(r: GitRepo, p: PrState): PullRequest | null {
335 if (!p || p.branch !== (r.branch ?? '') || !p.pr) return null
336 const pr = p.pr
337 if (pr.state !== 'OPEN' && pr.head && r.head && pr.head.slice(0, r.head.length) !== r.head) return null
338 return pr
339}
340
341export function glyphOf(f: GitFile) {
342 // Mêmes glyphes et couleurs que les groupes, les pastilles et le bandeau.
343 if (f.kind === 'conflict') return { letter: '✖', color: C.red }
344 if (f.kind === 'untracked') return { letter: '+', color: C.teal }
345 const x = f.work !== '.' ? f.work : f.index
346 return { letter: x, color: x === 'A' ? C.green : x === 'D' ? C.red : x === 'R' || x === 'C' ? C.blue : C.amber }
347}
348
349export function prLabel(p: PullRequest) {
350 if (p.state === 'MERGED') return 'Fusionnée'
351 if (p.state === 'CLOSED') return 'Fermée'
352 return p.isDraft ? 'Brouillon' : 'Ouverte'
353}
354
355export function prColor(p: PullRequest) {
356 if (p.state === 'MERGED') return C.violet
357 if (p.state === 'CLOSED') return C.red
358 return p.isDraft ? C.gray : C.green
359}
360
361export function reviewOf(review: string | null): [string, string] | null {
362 if (review === 'APPROVED') return ['approuvée', C.green]
363 if (review === 'CHANGES_REQUESTED') return ['modifications demandées', C.red]
364 if (review === 'REVIEW_REQUIRED') return ['relecture attendue', C.gray]
365 return null
366}
367
368export function checksText(c: PrChecks): [string, string][] {
369 return [
370 c.passed ? ([`✓${NB}${c.passed}`, C.green] as [string, string]) : null,
371 c.failed ? ([`✗${NB}${c.failed}`, C.red] as [string, string]) : null,
372 c.pending ? ([`◌${NB}${c.pending}`, C.amber] as [string, string]) : null,
373 ].filter((x): x is [string, string] => x !== null)
374}
375
376/**
377 * « fetch il y a 4 min », « fetch en échec », ou « fetch jamais » quand ni la session ni
378 * `FETCH_HEAD` ne donnent d'âge : « ✓ à jour » ne vaut que ce que vaut le dernier fetch.
379 * Rien sans distant (il n'y a rien à récupérer).
380 */
381export function fetchedText(m: Meta, now: number, hasRemote: boolean): { text: string; isError: boolean } | null {
382 if (m.fetchError) return { text: `fetch${NB}en${NB}échec`, isError: true }
383 if (m.fetchedAt) return { text: `fetch${NB}${ago(m.fetchedAt, now)}`, isError: false }
384 return hasRemote ? { text: `fetch${NB}jamais`, isError: false } : null
385}
386
387export function gitError(res: Run) {
388 const lines = res.stderr
389 .split('\n')
390 .map((x) => x.trim())
391 .filter(Boolean)
392 return lines.slice(0, 4).join(' ') || `git a échoué (code ${res.exitCode})`
393}
394
395export function firstLine(text: string) {
396 return text.split('\n').map((x) => x.trim()).find(Boolean) ?? ''
397}
398
399// origin/feat/x → feat/x
400export function localOf(remote: string) {
401 return remote.replace(/^[^/]+\//, '')
402}
403
404/** Comparaison de dossiers insensible à la casse et aux `\` / `/` (Windows). */
405export function sameDir(a: string, b: string) {
406 const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
407 return norm(a) === norm(b)
408}
409
410export function unique(list: string[]) {
411 return [...new Set(list)]
412}
413
414export function plural(n: number, one: string, many = `${one}s`) {
415 return `${n}${NB}${n > 1 ? many : one}`
416}
417
418/** Âge relatif : « à l'instant », « il y a 4 min », « hier », puis la date. */
419export function ago(ms: number, now: number) {
420 const s = Math.max(0, Math.round((now - ms) / 1000))
421 if (s < 60) return `à${NB}l'instant`
422 const min = Math.round(s / 60)
423 if (min < 60) return `il${NB}y${NB}a${NB}${min}${NB}min`
424 const h = Math.round(min / 60)
425 if (h < 24) return `il${NB}y${NB}a${NB}${h}${NB}h`
426 const d = Math.round(h / 24)
427 if (d === 1) return 'hier'
428 if (d < 30) return `il${NB}y${NB}a${NB}${d}${NB}j`
429 const date = new Date(ms)
430 return `${pad(date.getDate())}/${pad(date.getMonth() + 1)}/${date.getFullYear()}`
431}
432
433function pad(n: number) {
434 return String(n).padStart(2, '0')
435}
436
437/** Arguments réservés de /git : les onglets (identifiants ou libellés) et hide / show. */
438export function tabOfArg(arg: string): 'changes' | 'branches' | 'history' | null {
439 const a = arg.toLowerCase()
440 if (a === 'changes' || a === 'modifications') return 'changes'
441 if (a === 'branches') return 'branches'
442 if (a === 'history' || a === 'historique') return 'history'
443 return null
444}
445
446/**
447 * Une commande /project lancée du panneau a-t-elle fait son effet ? Le dépôt relu le dit sans
448 * attendre la fin du tour de Claude, qui peut enchaîner sur tout autre chose dans le même tour.
449 * Commit : HEAD a bougé. Sync : un fetch depuis le lancement et plus rien à récupérer. Push :
450 * plus rien à pousser vers le suivi. PR : une PR existe pour HEAD (voir `prOfBranch`). Une commande sans ces
451 * repères (lancée par une version précédente) n'est finie qu'avec le tour.
452 */
453export function isRunDone(run: NonNullable<Running>, r: GitRepo, m: Meta, p: PrState): boolean {
454 if (!run.isStarted || run.startedAt === undefined) return false
455 if (run.action === 'commit') return run.head !== undefined && r.head !== null && r.head !== run.head
456 if (run.action === 'pull') return (m.fetchedAt ?? 0) >= run.startedAt && r.behind === 0 && !r.operation
457 if (run.action === 'push') return Boolean(r.upstream) && r.ahead === 0
458 if (run.action === 'pr') return prOfBranch(r, p) !== null
459 return false
460}
461hooks/view.tsx 1148 lines1// Dessin du panneau Git (/git) et du bandeau au-dessus du prompt. Un seul arbre pour
2// toutes les surfaces : le desktop, VS Code et le mobile reçoivent les SVG, le terminal
3// des glyphes et des barres en blocs. Aucun appel au moteur ici : l'état arrive dans
4// `View`, les actions dans `Actions` (des fermetures préparées par register.tsx).
5//
6// Pièges du desktop : les espaces ordinaires y sont fondus et `gap` ignoré ; les écarts
7// dans un texte sont donc insécables, ceux entre éléments des marges. Aucune prop n'est
8// passée à `undefined` : les arbres restent valides partout. Le mobile n'a ni `Input` ni
9// `Select` : le panneau s'en passe et le dit en une ligne atténuée.
10
11import type { Action, CiWatch, Expanded, Form, GitBranch, GitCommit, GitFile, GitRemoteBranch, GitRepo, Meta, Notice, PrState, PullRequest, Running, Switching, Tab } from '../types'
12import {
13 ACTION_COLOR,
14 C,
15 MAX_FILES,
16 NB,
17 OPERATION,
18 SEP,
19 ago,
20 branchLabel,
21 checksText,
22 ciOf,
23 ciText,
24 commandFor,
25 countsOf,
26 fetchedText,
27 glyphOf,
28 localOf,
29 plural,
30 prColor,
31 prLabel,
32 prOfBranch,
33 reviewOf,
34 sameDir,
35 slash,
36 VERSION_COMMAND,
37 suggest,
38 unique,
39} from './model.ts'
40import { barSvg, gaugeSvg, roadmapSvg, spinnerSvg, type BarPart, type NodeState, type RoadNode } from './svg.ts'
41
42/** Largeur estimée d'une cellule sur les surfaces distantes ; un SVG trop large est réduit à sa place. */
43const PX_PER_CELL = 7.6
44/** Plafonds des listes avant « ▸ tout voir (N) ». */
45const FILES_CAP = 20
46const BRANCHES_CAP = 12
47const COMMITS_CAP = 12
48
49export type Kit = {
50 Box: any
51 Text: any
52 Button: any
53 Svg: any
54 /** Lien externe ; absent d'une table qui n'en a pas. */
55 Link: any
56 /** Champ de saisie et liste de choix : absents sur le mobile. */
57 Input: any
58 Select: any
59 /** La surface dessine les SVG (desktop, VS Code, mobile). */
60 isRich: boolean
61 /** Colonnes du corps. */
62 width: number
63}
64
65/**
66 * Ce que la surface dessine vraiment. La table de `$.ui.resolve(e)` est complétée : un
67 * élément absent de la surface y figure quand même et dessine un fragment vide (constaté
68 * dans le kit de test : `Svg` sur le terminal, `Input` et `Select` sur le mobile). Un
69 * `typeof table.Svg` ne suffit donc pas : register.tsx lit ces capacités dans le contrat
70 * `Elements` du moteur et les passe ici, la vue ne connaît pas le nom de la surface.
71 */
72export type Caps = { hasSvg: boolean; hasFields: boolean }
73
74export const kitOf = (table: Record<string, unknown>, width: number, caps: Caps): Kit => ({
75 Box: table.Box,
76 Text: table.Text,
77 Button: table.Button,
78 Svg: caps.hasSvg ? (table.Svg ?? null) : null,
79 Link: table.Link ?? null,
80 Input: caps.hasFields ? (table.Input ?? null) : null,
81 Select: caps.hasFields ? (table.Select ?? null) : null,
82 isRich: caps.hasSvg && typeof table.Svg === 'function',
83 width: Math.max(24, Math.floor(width)),
84})
85
86export type View = {
87 repo: GitRepo | null
88 pr: PrState
89 meta: Meta
90 tab: Tab
91 action: Action | null
92 form: Form
93 notice: Notice
94 busy: string | null
95 running: Running
96 switching: Switching
97 showRemotes: boolean
98 expanded: Expanded
99 isBandHidden: boolean
100 /** Suivi de la CI après un push ; affiché seulement pour la branche et le commit en tête. */
101 ci: CiWatch
102 now: number
103}
104
105export type Actions = {
106 setTab: (tab: Tab) => unknown
107 /** Ouvre le tiroir d'une action, ou le referme. */
108 toggleAction: (a: Action) => unknown
109 closeAction: () => unknown
110 setForm: (patch: Partial<Form>) => unknown
111 /** ↻ : oublie une commande restée en file, puis relit tout (fetch et PR compris). */
112 refresh: () => unknown
113 launch: (a: Action) => unknown
114 /** Lance /project:version, suggérée avant le push. */
115 launchVersion: () => unknown
116 openFolder: (value: string) => unknown
117 /** Revient au dossier de la session. */
118 backToSession: () => unknown
119 askSwitch: (name: string, isRemote: boolean) => unknown
120 confirmSwitch: (name: string, isRemote: boolean) => unknown
121 cancelSwitch: () => unknown
122 createBranch: (value: string) => unknown
123 toggleRemotes: () => unknown
124 toggleExpanded: (list: keyof Expanded) => unknown
125 dismissNotice: () => unknown
126 hideBand: () => unknown
127 openPane: () => unknown
128}
129
130// ─── Petits outils ────────────────────────────────────────────────────────
131
132const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
133const spinFrame = (now: number): string => SPIN[Math.floor(now / 1000) % SPIN.length] ?? '⠋'
134const px = (cells: number): number => Math.max(160, Math.floor(cells * PX_PER_CELL))
135
136type Style = { color?: string; bg?: string; bold?: boolean; dim?: boolean; italic?: boolean; strike?: boolean; wrap?: string }
137
138const propsOf = (style: Style): Record<string, unknown> => {
139 const props: Record<string, unknown> = {}
140 if (style.color) props.color = style.color
141 if (style.bg) props.backgroundColor = style.bg
142 if (style.bold) props.bold = true
143 if (style.dim) props.dimColor = true
144 if (style.italic) props.italic = true
145 if (style.strike) props.strikethrough = true
146 if (style.wrap) props.wrap = style.wrap
147 return props
148}
149
150/** Un Text sans prop indéfinie. */
151const T = (k: Kit, text: string, style: Style = {}): unknown => h(k.Text, propsOf(style), text)
152
153/** Un Text fait de morceaux (Text imbriqués ou chaînes), sans prop indéfinie. */
154const TT = (k: Kit, parts: unknown[], style: Style = {}): unknown => h(k.Text, propsOf(style), ...parts.filter(isShown))
155
156/** Pastille pleine : texte foncé sur la couleur. `style` ajoute par exemple une troncature. */
157const chip = (k: Kit, text: string, color: string, style: Style = {}): unknown => T(k, `${NB}${text}${NB}`, { ...style, bg: color, color: C.ink, bold: true })
158
159const isShown = (item: unknown): boolean => item !== null && item !== undefined && item !== false && item !== ''
160
161/** Une rangée d'éléments séparés par une marge (le desktop ignore `gap`). */
162const line = (k: Kit, items: unknown[], wrap = false): unknown => {
163 const { Box } = k
164 const list = items.filter(isShown)
165 return (
166 <Box flexDirection="row" alignItems="center" flexWrap={wrap ? 'wrap' : 'nowrap'}>
167 {list.map((item, i) => (i < list.length - 1 ? <Box marginRight={1}>{item}</Box> : item))}
168 </Box>
169 )
170}
171
172const svgOf = (k: Kit, drawn: { source: string; width: number; height: number }, alt: string, isSized = true): unknown => {
173 const props: Record<string, unknown> = { source: drawn.source, alt }
174 if (isSized) {
175 props.width = drawn.width
176 props.height = drawn.height
177 }
178 // Jamais `isInteractive` : ce mode dessine dans un cadre à fond blanc, rechargé (donc
179 // clignotant) à chaque redessin. En image, le fond reste transparent et le dessin stable.
180 return h(k.Svg, props)
181}
182
183/** Barre en blocs du terminal. */
184const blocks = (k: Kit, parts: readonly BarPart[], total: number, cells: number): unknown => {
185 const { Box } = k
186 const width = Math.max(6, cells)
187 const sum = Math.max(1, total, parts.reduce((n, p) => n + p.value, 0))
188 let used = 0
189 const segs: unknown[] = []
190 parts.forEach((part) => {
191 if (part.value <= 0) return
192 const n = Math.max(1, Math.round((part.value / sum) * width))
193 const take = Math.min(n, width - used)
194 if (take <= 0) return
195 used += take
196 segs.push(T(k, '█'.repeat(take), { color: part.color }))
197 })
198 if (used < width) segs.push(T(k, '░'.repeat(width - used), { color: C.gray, dim: true }))
199 return <Box flexDirection="row">{segs}</Box>
200}
201
202/** Indicateur de travail : arc qui tourne (SVG) ou spinner braille (terminal). */
203const spinner = (k: Kit, color: string, now: number, alt: string): unknown =>
204 k.isRich ? svgOf(k, spinnerSvg(color, 12), alt) : T(k, spinFrame(now), { color })
205
206/** Ce que la surface ne permet pas, dit en une ligne atténuée plutôt que caché en silence. */
207const unavailable = (k: Kit, text: string, marginTop = 1): unknown => {
208 const { Box } = k
209 return <Box marginTop={marginTop}>{T(k, `⊘${NB}${text}`, { dim: true, wrap: 'wrap' })}</Box>
210}
211
212/** Bascule « ▸ tout voir (N) » / « ▾ réduire » d'une liste plafonnée. */
213const showAllToggle = (k: Kit, key: string, isOpen: boolean, total: number, onPress: () => unknown): unknown => {
214 const { Box, Button } = k
215 return (
216 <Box marginTop={1}>
217 <Button key={key} plain={true} dimColor={true} label={isOpen ? `▾${NB}réduire` : `▸${NB}tout${NB}voir${NB}(${total})`} onPress={onPress} />
218 </Box>
219 )
220}
221
222const TONE = { ok: C.green, error: C.red, info: C.tangerine } as const
223
224/** Couleur d'identité du mod (règle 8 : le violet est la marque du mod ; la mandarine dit « en cours »). */
225const BRAND = C.violet
226
227// ─── Route de livraison (Commit → Push → PR → Checks → Fusion) ────────────
228
229/** Formes courtes de la route en texte, quand le libellé entier ne tient pas. */
230const SHORT_LABEL: Record<string, string> = { Commit: 'Com.', Checks: 'CI', Fusion: 'Fus.' }
231
232const STATE_WORD: Record<NodeState, string> = { done: 'fait', active: 'en cours', next: 'à lancer', pending: 'à venir', skipped: 'sauté' }
233
234const roadNodes = (r: GitRepo, pr: PullRequest | null, watch: NonNullable<CiWatch> | null = null): RoadNode[] => {
235 if (!r.hasRemote || !r.branch) return []
236 const n = countsOf(r)
237 const steps: { label: string; sub: string; color: string; isDone: boolean }[] = [
238 { label: 'Commit', sub: r.files.length ? plural(r.files.length, 'fichier') : 'propre', color: n.conflicts ? C.red : C.green, isDone: r.files.length === 0 },
239 {
240 label: 'Push',
241 sub: !r.upstream ? 'non publiée' : r.ahead ? `↑${r.ahead}` : 'à jour',
242 color: C.cyan,
243 isDone: Boolean(r.upstream) && r.ahead === 0,
244 },
245 ]
246 if (r.webUrl && r.branch !== r.defaultBranch) {
247 const live = pr && pr.state !== 'CLOSED' ? pr : null
248 steps.push({ label: 'PR', sub: live ? `#${live.number}` : 'à ouvrir', color: C.violet, isDone: live !== null })
249 // Pendant le suivi de la CI, ses checks l'emportent : « en attente » tant que rien n'est
250 // lancé, au lieu d'un « aucun » fait (sans suivi, les deux se confondent).
251 const ch = live ? (watch?.checks ?? live.checks) : undefined
252 const hasChecks = ch !== undefined && ch.passed + ch.failed + ch.pending > 0
253 const isWaiting = Boolean(live) && watch?.phase === 'waiting'
254 steps.push({
255 label: 'Checks',
256 sub: !ch ? '' : isWaiting ? 'attente' : ch.failed ? `✗${ch.failed}` : ch.pending ? `◌${ch.pending}` : hasChecks ? `✓${ch.passed}` : 'aucun',
257 color: ch && ch.failed ? C.red : C.cyan,
258 isDone: Boolean(live) && ch !== undefined && !isWaiting && ch.failed === 0 && ch.pending === 0,
259 })
260 steps.push({ label: 'Fusion', sub: live?.state === 'MERGED' ? 'fusionnée' : '', color: C.violet, isDone: live?.state === 'MERGED' })
261 }
262 // Une route se parcourt dans l'ordre : la première étape non faite est active, et toutes
263 // les suivantes sont à venir, même si leur état propre dit « fait ». Un commit à pousser
264 // rend la PR, les checks et la fusion d'avant caducs : il en faudra de nouveaux. Leur
265 // sous-titre ne garde que le numéro d'une PR encore ouverte, que le push mettra à jour.
266 let isOpen = true
267 return steps.map((s) => {
268 let state: NodeState = 'pending'
269 let sub = s.sub
270 if (s.isDone && isOpen) state = 'done'
271 else if (isOpen) {
272 state = 'active'
273 isOpen = false
274 } else if (!(s.label === 'PR' && pr?.state === 'OPEN')) sub = ''
275 return sub ? { label: s.label, sub, color: s.color, state } : { label: s.label, color: s.color, state }
276 })
277}
278
279/** La route en texte (repli terminal) : glyphes reliés, libellés dessous. */
280const textRoad = (k: Kit, nodes: readonly RoadNode[], cells: number): unknown => {
281 const { Box } = k
282 const n = nodes.length
283 const slot = Math.max(3, Math.floor((cells - 1) / Math.max(1, n)))
284 const glyph = (x: RoadNode): string => (x.state === 'done' ? '●' : x.state === 'active' ? '◉' : x.state === 'next' ? '◎' : x.state === 'skipped' ? '·' : '○')
285 const marks: unknown[] = []
286 const labels: unknown[] = []
287 nodes.forEach((x, i) => {
288 const isLit = x.state !== 'pending' && x.state !== 'skipped'
289 marks.push(T(k, glyph(x), { color: isLit ? x.color : C.gray, bold: x.state === 'active' }))
290 if (i < n - 1) {
291 const isDone = x.state === 'done' || x.state === 'skipped'
292 marks.push(T(k, (isDone ? '━' : x.state === 'active' ? '╍' : '┄').repeat(slot - 1), { color: isDone ? x.color : C.gray, dim: !isDone }))
293 }
294 // Un libellé entier ou sa forme courte fixe, jamais coupé au milieu du mot.
295 const showLabel = slot >= 6 || x.state === 'active'
296 const short = SHORT_LABEL[x.label] ?? x.label
297 const text = !showLabel ? '' : x.label.length <= slot - 1 ? x.label : short.length <= slot - 1 ? short : ''
298 labels.push(T(k, i < n - 1 ? text.padEnd(slot) : text, { color: x.state === 'active' ? x.color : '', bold: x.state === 'active', dim: x.state !== 'active' }))
299 })
300 return (
301 <Box flexDirection="column" marginTop={1}>
302 <Box flexDirection="row">{marks}</Box>
303 <Box flexDirection="row">{labels}</Box>
304 </Box>
305 )
306}
307
308const roadmap = (k: Kit, r: GitRepo, pr: PullRequest | null, watch: NonNullable<CiWatch> | null, cells: number): unknown => {
309 const { Box } = k
310 const nodes = roadNodes(r, pr, watch)
311 if (nodes.length === 0) return null
312 if (!k.isRich) return textRoad(k, nodes, cells)
313 const alt = nodes.map((x) => `${x.label} : ${STATE_WORD[x.state]}${x.sub ? ` (${x.sub})` : ''}`).join(', ')
314 return <Box marginTop={1}>{svgOf(k, roadmapSvg(nodes, px(cells)), alt, false)}</Box>
315}
316
317// ─── Panneau ──────────────────────────────────────────────────────────────
318
319export const drawPane = (k: Kit, view: View, act: Actions): unknown => {
320 const { Box } = k
321 const r = view.repo
322 if (!r) {
323 return (
324 <Box flexDirection="column" paddingX={1} paddingBottom={1}>
325 {welcome(k, view, act)}
326 {activity(k, view, act)}
327 </Box>
328 )
329 }
330 const body = view.tab === 'branches' ? branchesTab(k, view, r, act) : view.tab === 'history' ? historyTab(k, view, r, act) : changesTab(k, view, r, act)
331 return (
332 <Box flexDirection="column" paddingX={1} paddingBottom={1}>
333 {hero(k, view, r, act)}
334 {activity(k, view, act)}
335 {operationCard(k, r)}
336 {actionBar(k, view, r, act)}
337 {drawer(k, view, r, act)}
338 {tabsRow(k, view, r, act)}
339 {body}
340 </Box>
341 )
342}
343
344/** Sans dépôt : une carte d'accueil, jamais un panneau vide. */
345const welcome = (k: Kit, view: View, act: Actions): unknown => {
346 const { Box, Button, Input } = k
347 const m = view.meta
348 return (
349 <Box key="empty" flexDirection="column" borderStyle="round" borderColor={C.violet} paddingX={2} paddingY={1}>
350 {/* Pendant une lecture, l'indicateur est dans la zone d'activité, juste dessous. */}
351 {T(k, m.loading ? `◆${NB}Git` : `◆${NB}Pas de dépôt git ici`, { color: C.violet, bold: true })}
352 {m.error && T(k, m.error, { dim: true, wrap: 'wrap' })}
353 {T(k, 'Le panneau se remplit dès que le dossier suivi est dans un dépôt git.', { dim: true, wrap: 'wrap' })}
354 {Input ? (
355 <Box flexDirection="column" marginTop={1}>
356 <Input key="folder" label="Dossier" placeholder="chemin d'un dépôt git" submitLabel="ouvrir" onSubmit={(v: string) => act.openFolder(v)} />
357 <Box marginTop={1}>{T(k, 'ou /git <dossier>', { dim: true })}</Box>
358 </Box>
359 ) : (
360 unavailable(k, 'Saisie du dossier indisponible ici : tape /git <dossier>.')
361 )}
362 {m.folder !== null && (
363 <Box marginTop={1}>
364 <Button key="back-session" label={`↩${NB}Dossier${NB}de${NB}la${NB}session`} onPress={() => act.backToSession()} />
365 </Box>
366 )}
367 </Box>
368 )
369}
370
371/**
372 * Carte d'en-tête : pastille de la branche, jauge ↑/↓ (SVG) à gauche du dépôt et de sa
373 * méta, état en pastilles par gravité, PR sur une ligne, route. Chaque chiffre n'y est
374 * dit qu'une fois : sur une surface riche la jauge porte ↑/↓, ailleurs ce sont les pastilles.
375 */
376const hero = (k: Kit, view: View, r: GitRepo, act: Actions): unknown => {
377 const { Box, Button, Link } = k
378 const m = view.meta
379 const n = countsOf(r)
380 const pr = prOfBranch(r, view.pr)
381 const prs = view.pr
382 const watch = ciOf(r, view.ci)
383 const inner = k.width - 6
384 const color = r.operation || n.conflicts ? C.red : r.branch ? BRAND : C.gray
385 const isHere = m.sessionRoot !== null && sameDir(m.sessionRoot, r.root)
386 const fetched = fetchedText(m, view.now, r.hasRemote)
387 const hasGauge = k.isRich && Boolean(r.upstream)
388
389 // L'état, par gravité : ce qui bloque d'abord, l'anecdotique (stash) en dernier.
390 const pills: [string, string][] = []
391 if (r.operation) pills.push([`${OPERATION[r.operation] ?? r.operation}${NB}en${NB}cours`, C.red])
392 if (n.conflicts) pills.push([`✖${NB}${n.conflicts}${NB}en${NB}conflit`, C.red])
393 if (r.branch && r.upstream) {
394 if (r.behind && !hasGauge) pills.push([`↓${NB}${r.behind}${NB}à${NB}récupérer`, C.amber])
395 if (r.ahead && !hasGauge) pills.push([`↑${NB}${r.ahead}${NB}à${NB}pousser`, C.green])
396 if (!r.ahead && !r.behind) pills.push([`✓${NB}à${NB}jour`, C.green])
397 } else if (r.branch && r.hasRemote) {
398 pills.push([`◌${NB}non${NB}publiée`, C.amber])
399 }
400 if (n.changed) pills.push([`●${NB}${n.changed}${NB}modifié${n.changed > 1 ? 's' : ''}`, C.amber])
401 if (n.fresh) pills.push([`+${NB}${n.fresh}${NB}nouveau${n.fresh > 1 ? 'x' : ''}`, C.teal])
402 if (r.stash) pills.push([`⚑${NB}${r.stash}${NB}stash`, C.slate])
403
404 const gauge = hasGauge
405 ? svgOf(k, gaugeSvg(r.ahead, r.behind, C.green, C.amber), `${r.ahead} commit${r.ahead > 1 ? 's' : ''} à pousser, ${r.behind} à récupérer`)
406 : null
407 const meta = [r.upstream ? `suit${NB}${r.upstream}` : r.hasRemote ? 'aucune branche distante suivie' : 'pas de distant', fetched && !fetched.isError ? fetched.text : null]
408 .filter(Boolean)
409 .join(SEP)
410
411 // La pastille vivante ne dit que la commande /project ; lectures et bascules sont dans
412 // la zone d'activité, juste dessous.
413 const pending = view.running
414 const live = pending !== null ? T(k, pending.isStarted ? `◉${NB}Claude${NB}travaille` : `▶${NB}en${NB}file`, { color: C.tangerine, bold: true }) : null
415
416 const review = pr ? reviewOf(pr.review) : null
417 const prLine = pr ? (
418 <Box key="pr" flexDirection="row" justifyContent="space-between" alignItems="center" marginTop={1}>
419 <Box flexShrink={1}>
420 {TT(
421 k,
422 [
423 T(k, `⇄${NB}#${pr.number}`, { color: C.violet, bold: true }),
424 T(k, SEP, { dim: true }),
425 T(k, prLabel(pr), { color: prColor(pr), bold: true }),
426 ...(review ? [T(k, SEP, { dim: true }), T(k, review[0], { color: review[1] })] : []),
427 ...(watch ? [ciText(watch)] : checksText(pr.checks)).flatMap(([text, c]) => [T(k, SEP, { dim: true }), T(k, text, { color: c })]),
428 pr.title ? T(k, `${SEP}${pr.title}`, { dim: true }) : '',
429 ],
430 { wrap: 'truncate-end' },
431 )}
432 </Box>
433 {Link && pr.url && (
434 <Box flexShrink={0} marginLeft={1}>
435 <Link href={pr.url} label="Ouvrir ↗" />
436 </Box>
437 )}
438 </Box>
439 ) : prs && prs.branch === (r.branch ?? '') && !prs.error && r.webUrl && r.branch && r.branch !== r.defaultBranch ? (
440 <Box key="no-pr" marginTop={1}>
441 {T(k, `⇄${NB}pas encore de pull request pour cette branche`, { dim: true, wrap: 'truncate-end' })}
442 </Box>
443 ) : null
444
445 return (
446 <Box key="head" flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
447 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
448 <Box flexShrink={1}>{chip(k, `⎇${NB}${branchLabel(r)}`, color, { wrap: 'truncate-end' })}</Box>
449 <Box flexDirection="row" flexShrink={0} marginLeft={1} alignItems="center">
450 {live && <Box marginRight={1}>{live}</Box>}
451 {m.folder !== null && (
452 <Box marginRight={1}>
453 <Button key="back-session" plain={true} dimColor={true} label={`↩${NB}session`} onPress={() => act.backToSession()} />
454 </Box>
455 )}
456 <Button key="refresh" plain={true} label={`↻${NB}Relire`} onPress={() => act.refresh()} />
457 </Box>
458 </Box>
459 <Box flexDirection="row" alignItems="center" marginTop={1}>
460 {gauge && (
461 <Box marginRight={2} flexShrink={0}>
462 {gauge}
463 </Box>
464 )}
465 <Box flexDirection="column" flexShrink={1}>
466 {TT(k, [T(k, r.name, { bold: true }), T(k, `${SEP}${meta}`, { dim: true })], { wrap: 'truncate-end' })}
467 {fetched && fetched.isError && T(k, `${fetched.text}${m.fetchError ? ` : ${m.fetchError}` : ''}`, { color: C.red, wrap: 'truncate-end' })}
468 {!isHere && T(k, `↗${NB}hors du dossier de la session : les commandes /project n'agissent pas ici`, { color: C.amber, wrap: 'truncate-end' })}
469 </Box>
470 </Box>
471 {pills.length > 0 && <Box marginTop={1}>{line(k, pills.map(([text, c]) => chip(k, text, c)), true)}</Box>}
472 {prLine}
473 {/* Sans PR, la CI suivie après un push a sa propre ligne. */}
474 {!pr && watch && <Box marginTop={1}>{T(k, ciText(watch)[0], { color: ciText(watch)[1], wrap: 'truncate-end' })}</Box>}
475 {prs && prs.branch === (r.branch ?? '') && prs.error && <Box marginTop={1}>{T(k, prs.error, { color: C.amber, wrap: 'truncate-end' })}</Box>}
476 {roadmap(k, r, pr, watch, inner)}
477 </Box>
478 )
479}
480
481/** La zone d'activité, toujours juste sous la carte d'en-tête. */
482const activity = (k: Kit, view: View, act: Actions): unknown => {
483 const { Box, Button } = k
484 const m = view.meta
485 const run = view.running
486 const said = view.notice
487 const rows = [
488 m.loading && line(k, [spinner(k, C.gray, view.now, 'lecture'), T(k, m.loading, { color: C.gray })]),
489 view.busy && line(k, [spinner(k, C.tangerine, view.now, 'git au travail'), T(k, view.busy, { color: C.tangerine })]),
490 run &&
491 (run.isStarted
492 ? T(k, `◉${NB}${run.command} en cours dans la conversation…`, { color: C.tangerine, wrap: 'wrap' })
493 : T(k, `▶${NB}${run.command} en file : il part dès que Claude est libre.`, { color: C.tangerine, wrap: 'wrap' })),
494 said && (
495 <Box flexDirection="row" justifyContent="space-between">
496 <Box flexShrink={1}>{T(k, said.text, { color: TONE[said.tone], wrap: 'wrap' })}</Box>
497 {said.tone === 'error' && (
498 <Box flexShrink={0} marginLeft={1}>
499 <Button key="notice-dismiss" plain={true} dimColor={true} label="x" onPress={() => act.dismissNotice()} />
500 </Box>
501 )}
502 </Box>
503 ),
504 ].filter(isShown)
505 if (rows.length === 0) return null
506 return (
507 <Box key="activity" flexDirection="column" marginTop={1} paddingX={1}>
508 {rows}
509 </Box>
510 )
511}
512
513const operationCard = (k: Kit, r: GitRepo): unknown => {
514 const { Box } = k
515 if (!r.operation) return null
516 const n = countsOf(r).conflicts
517 return (
518 <Box key="operation" flexDirection="column" borderStyle="round" borderColor={C.red} paddingX={1} marginTop={1}>
519 {T(k, `${OPERATION[r.operation] ?? r.operation} en cours`, { color: C.red, bold: true })}
520 {T(k, n ? `${plural(n, 'fichier')} en conflit : résous-les, puis termine ou annule l'opération.` : "Termine-la ou annule-la avant de commiter ou de changer de branche.", { dim: true, wrap: 'wrap' })}
521 </Box>
522 )
523}
524
525/**
526 * Barre d'actions : un seul `primary`, l'étape suggérée, et seulement tant que son tiroir
527 * est fermé (le tiroir porte alors « ▶ Lancer »). Le tiroir ouvert est marqué « ▾ ». Pas
528 * de comptes dans les libellés : l'en-tête les dit déjà.
529 */
530const actionBar = (k: Kit, view: View, r: GitRepo, act: Actions): unknown => {
531 const { Box, Button } = k
532 const lead = suggest(r, view.pr)
533 const pr = prOfBranch(r, view.pr)
534 const button = (a: Action, glyph: string, label: string) => {
535 const props: Record<string, unknown> = { key: `act-${a}`, label: `${view.action === a ? '▾' : glyph}${NB}${label}`, onPress: () => act.toggleAction(a) }
536 if (a === lead && view.action !== a) props.variant = 'primary'
537 return h(Button, props)
538 }
539 return (
540 <Box key="bar" marginTop={1}>
541 {line(
542 k,
543 [
544 button('commit', '✓', 'Commit'),
545 button('pull', '↓', 'Pull'),
546 button('push', '↑', 'Push'),
547 button('pr', '⇄', pr && pr.state === 'OPEN' ? `PR${NB}#${pr.number}` : `Pull${NB}request`),
548 ],
549 true,
550 )}
551 </Box>
552 )
553}
554
555// ─── Tiroir de l'action choisie ─────────────────────────────────────────
556
557const drawer = (k: Kit, view: View, r: GitRepo, act: Actions): unknown => {
558 const { Box, Button, Input, Select, Link } = k
559 const open = view.action
560 if (!open) return null
561 const m = view.meta
562 const f = view.form
563 const n = countsOf(r)
564 const pr = prOfBranch(r, view.pr)
565 // Les commandes /project agissent sur le dossier de la session, pas sur celui du panneau.
566 const isHere = m.sessionRoot !== null && sameDir(m.sessionRoot, r.root)
567 const say = (text: string, color = '') => <Box marginTop={1}>{T(k, text, color ? { color, wrap: 'wrap' } : { dim: true, wrap: 'wrap' })}</Box>
568 const card = (a: Action, title: string, body: unknown[], command: string | null) => (
569 <Box key={`drawer-${a}`} flexDirection="column" borderStyle="round" borderColor={ACTION_COLOR[a]} paddingX={1} marginTop={1}>
570 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
571 {T(k, title, { color: ACTION_COLOR[a], bold: true })}
572 {/* Pas de role="dismiss" : sur le desktop, il deviendrait la fermeture native du panneau. */}
573 <Button key="drawer-close" plain={true} dimColor={true} label="✕" onPress={() => act.closeAction()} />
574 </Box>
575 {body.filter(isShown)}
576 {command && !isHere && say(`Les commandes /project agissent sur le dossier de la session${m.sessionRoot ? ` (${m.sessionRoot})` : ''} : ouvre une session dans ${r.root} pour les lancer d'ici.`, C.amber)}
577 {command && isHere && (
578 <Box flexDirection="row" alignItems="center" marginTop={1}>
579 {line(k, [<Button key="launch" variant="primary" label={`▶${NB}Lancer${NB}·${NB}${command}`} onPress={() => act.launch(a)} />, T(k, 'part dès que Claude est libre', { dim: true })])}
580 </Box>
581 )}
582 </Box>
583 )
584
585 if (open === 'commit') {
586 if (!r.files.length) return card('commit', '✓ Commit', [say("Rien à commiter : l'arbre de travail est propre.")], null)
587 if (n.conflicts) return card('commit', '✓ Commit', [say('Résous d’abord les fichiers en conflit.', C.red)], null)
588 return card(
589 'commit',
590 '✓ Commit',
591 [
592 say('Claude lit le diff, rédige le message et commite (fichiers indexés par chemin).'),
593 Input ? (
594 <Box marginTop={1}>
595 <Input
596 key="hint"
597 label="Indice"
598 placeholder="facultatif : l'intention du changement"
599 value={f.hint}
600 submitLabel="lancer"
601 onInput={(v: string) => act.setForm({ hint: v })}
602 onSubmit={async (v: string) => {
603 await act.setForm({ hint: v })
604 if (isHere) await act.launch('commit')
605 }}
606 />
607 </Box>
608 ) : (
609 unavailable(k, "Indice indisponible ici : la commande part sans indice.")
610 ),
611 ],
612 slash(commandFor('commit', r, f)),
613 )
614 }
615 // HEAD détachée (aussi pendant un rebase) : ni pull, ni push, ni PR n'ont de branche.
616 if (!r.branch) {
617 const title = open === 'pull' ? '↓ Pull' : open === 'push' ? '↑ Push' : '⇄ Pull request'
618 return card(open, title, [say('HEAD détachée : bascule sur une branche (onglet Branches) avant pull, push ou PR.', C.amber)], null)
619 }
620 if (open === 'pull') {
621 const canBase = Boolean(r.defaultBranch && r.branch && r.branch !== r.defaultBranch)
622 const fromBase = f.pullFrom === 'base' && canBase
623 const options = [
624 { value: 'upstream', label: r.upstream ? `${r.upstream} (sa branche distante)` : 'sa branche distante' },
625 ...(canBase ? [{ value: 'base', label: `${r.defaultBranch} (branche par défaut)` }] : []),
626 ]
627 return card(
628 'pull',
629 '↓ Pull',
630 [
631 canBase &&
632 (Select ? (
633 <Box marginTop={1}>
634 <Select key="pull-from" label="Depuis" options={options} value={fromBase ? 'base' : 'upstream'} onSelect={(v: string) => act.setForm({ pullFrom: v === 'base' ? 'base' : 'upstream' })} />
635 </Box>
636 ) : (
637 unavailable(k, 'Choix de la source indisponible ici : le pull suit la branche distante.')
638 )),
639 fromBase
640 ? say(`Intègre origin/${r.defaultBranch} : merge si la branche est publiée, rebase sinon.`)
641 : r.upstream
642 ? say(`pull --rebase --autostash depuis ${r.upstream}.`)
643 : say("Branche pas encore publiée : rien à récupérer de son côté.", C.amber),
644 ],
645 fromBase || r.upstream ? slash(commandFor('pull', r, f)) : null,
646 )
647 }
648 if (open === 'push') {
649 const nothing = !r.files.length && !r.ahead && Boolean(r.upstream)
650 return card(
651 'push',
652 '↑ Push',
653 [
654 say('Commit du reste, contrôles du projet, pull --rebase puis push, jamais en force.'),
655 !r.hasRemote && say('Pas de dépôt distant : rien où pousser.', C.amber),
656 r.hasRemote && r.branch && r.branch === r.defaultBranch && say(`Tu es sur ${r.branch} : Claude demandera s'il faut y pousser ou créer une branche.`, C.amber),
657 r.hasRemote && nothing && say(`✓ Rien à pousser : tout est déjà sur ${r.upstream}.`, C.green),
658 // Le numéro de version et le CHANGELOG se montent avant de pousser, pas après.
659 r.hasRemote && !nothing && say(`Avant de pousser : ${VERSION_COMMAND} monte le numéro de version et le CHANGELOG d'après les commits.`, C.amber),
660 r.hasRemote && !nothing && isHere && (
661 <Box flexDirection="row" alignItems="center" marginTop={1}>
662 {line(k, [<Button key="launch-version" label={`▶${NB}D'abord${NB}·${NB}${VERSION_COMMAND}`} onPress={() => act.launchVersion()} />])}
663 </Box>
664 ),
665 ],
666 r.hasRemote && !nothing ? slash(commandFor('push', r, f)) : null,
667 )
668 }
669 // Pull request.
670 if (!r.webUrl) return card('pr', '⇄ Pull request', [say("Pas de distant GitHub : pas de pull request possible.", C.amber)], null)
671 if (pr && pr.state === 'OPEN') {
672 return card(
673 'pr',
674 `⇄ Pull request #${pr.number}`,
675 [
676 say(pr.title),
677 <Box marginTop={1}>
678 {line(k, [T(k, `${prLabel(pr)} vers ${pr.base}`, { color: prColor(pr) }), Link && pr.url && <Link href={pr.url} label="Ouvrir sur GitHub ↗" />])}
679 </Box>,
680 ],
681 null,
682 )
683 }
684 if (r.branch === r.defaultBranch) {
685 return card('pr', '⇄ Pull request', [say("Branche par défaut : crée d'abord une branche de travail (onglet Branches).", C.amber)], null)
686 }
687 const bases = unique([r.defaultBranch, ...r.branches.map((b) => b.name)].filter((b): b is string => Boolean(b) && b !== r.branch)).slice(0, 30)
688 const base = f.prBase && bases.includes(f.prBase) ? f.prBase : (bases[0] ?? '')
689 return card(
690 'pr',
691 '⇄ Pull request',
692 [
693 say('Titre et description tirés des commits ; la branche est poussée si besoin.'),
694 pr && say(`La dernière PR de cette branche (#${pr.number}) est ${prLabel(pr).toLowerCase()} : une nouvelle sera créée.`),
695 Select ? (
696 bases.length > 0 && (
697 <Box marginTop={1}>
698 {line(
699 k,
700 [
701 <Select key="pr-base" label="Vers" options={bases.map((b) => ({ value: b, label: b === r.defaultBranch ? `${b} (défaut)` : b }))} value={base} onSelect={(v: string) => act.setForm({ prBase: v })} />,
702 <Select
703 key="pr-draft"
704 label="État"
705 options={[
706 { value: 'ready', label: 'Prête à relire' },
707 { value: 'draft', label: 'Brouillon' },
708 ]}
709 value={f.prDraft ? 'draft' : 'ready'}
710 onSelect={(v: string) => act.setForm({ prDraft: v === 'draft' })}
711 />,
712 <Select
713 key="pr-auto"
714 label="Fusion"
715 options={[
716 { value: 'manual', label: 'Manuelle' },
717 { value: 'auto', label: 'Auto (squash)' },
718 ]}
719 value={f.prAuto ? 'auto' : 'manual'}
720 onSelect={(v: string) => act.setForm({ prAuto: v === 'auto' })}
721 />,
722 ],
723 true,
724 )}
725 </Box>
726 )
727 ) : (
728 unavailable(k, 'Base, brouillon et fusion auto indisponibles ici : PR prête à relire vers la branche par défaut.')
729 ),
730 ],
731 slash(commandFor('pr', r, f)),
732 )
733}
734
735// ─── Onglets ──────────────────────────────────────────────────────────────
736
737const tabsRow = (k: Kit, view: View, r: GitRepo, act: Actions): unknown => {
738 const { Box, Button } = k
739 const tabs: [Tab, string][] = [
740 ['changes', `Modifications${NB}·${NB}${r.files.length}`],
741 ['branches', `Branches${NB}·${NB}${r.branches.length + r.remotes.length}`],
742 ['history', `Historique${NB}·${NB}${r.commits.length}`],
743 ]
744 return (
745 <Box key="tabs" flexDirection="row" flexWrap="wrap" marginTop={1}>
746 {tabs.map(([tab, label]) => (
747 <Box marginRight={2}>
748 <Button key={`tab-${tab}`} plain={true} dimColor={tab !== view.tab} label={tab === view.tab ? `▸${NB}${label}` : label} onPress={() => act.setTab(tab)} />
749 </Box>
750 ))}
751 </Box>
752 )
753}
754
755// ─── Modifications ────────────────────────────────────────────────────────
756
757const GROUPS: { id: string; glyph: string; label: string; color: string; test: (f: GitFile) => boolean }[] = [
758 { id: 'conflict', glyph: '✖', label: 'En conflit', color: C.red, test: (f) => f.kind === 'conflict' },
759 { id: 'staged', glyph: '◆', label: 'Indexés', color: C.green, test: (f) => f.kind === 'changed' && f.work === '.' },
760 { id: 'changed', glyph: '●', label: 'Modifiés', color: C.amber, test: (f) => f.kind === 'changed' && f.work !== '.' },
761 { id: 'untracked', glyph: '+', label: 'Nouveaux', color: C.teal, test: (f) => f.kind === 'untracked' },
762]
763
764const changesTab = (k: Kit, view: View, r: GitRepo, act: Actions): unknown => {
765 const { Box } = k
766 if (!r.files.length) {
767 return (
768 <Box key="clean" flexDirection="column" borderStyle="round" borderDimColor={true} paddingX={1} marginTop={1}>
769 {T(k, `✓${NB}Arbre de travail propre`, { color: C.green, bold: true })}
770 {T(k, 'Rien à commiter. Les fichiers modifiés apparaîtront ici.', { dim: true, wrap: 'wrap' })}
771 </Box>
772 )
773 }
774 const n = countsOf(r)
775 const sorted = GROUPS.flatMap((g) => r.files.filter(g.test).sort((a, b) => a.path.localeCompare(b.path)))
776 const limit = view.expanded.files ? MAX_FILES : FILES_CAP
777 const shown = new Set(sorted.slice(0, limit))
778 const total = n.added + n.removed
779 const cells = Math.min(24, Math.max(8, k.width - 50))
780 const parts: BarPart[] = [
781 { value: n.added, color: C.green },
782 { value: n.removed, color: C.red },
783 ]
784 const bar = total > 0 ? (k.isRich ? svgOf(k, barSvg(parts, total, px(cells)), `${n.added} lignes ajoutées, ${n.removed} retirées`) : blocks(k, parts, total, cells)) : null
785 // L'onglet actif dit déjà le nombre de fichiers : la carte n'ouvre que sur le total des
786 // lignes et sa barre, quand il y en a.
787 const head =
788 total > 0 ? (
789 <Box key="diff-total" flexDirection="row" justifyContent="space-between" alignItems="center">
790 <Box flexShrink={1}>{line(k, [n.added > 0 && T(k, `+${n.added}`, { color: C.green }), n.removed > 0 && T(k, `−${n.removed}`, { color: C.red }), T(k, 'lignes', { dim: true })])}</Box>
791 {bar && k.width >= 56 && (
792 <Box flexShrink={0} marginLeft={1}>
793 {bar}
794 </Box>
795 )}
796 </Box>
797 ) : null
798 let isFirst = head === null
799 return (
800 <Box key="files" flexDirection="column" borderStyle="round" borderDimColor={true} paddingX={1} marginTop={1}>
801 {head}
802 {GROUPS.map((g) => {
803 const files = sorted.filter((f) => g.test(f))
804 const visible = files.filter((f) => shown.has(f))
805 if (visible.length === 0) return null
806 const top = isFirst ? 0 : 1
807 isFirst = false
808 return (
809 <Box key={`g-${g.id}`} flexDirection="column" marginTop={top}>
810 {line(k, [T(k, `${g.glyph}${NB}${g.label}`, { color: g.color, bold: true }), T(k, String(files.length), { dim: true })])}
811 {visible.map((f) => fileRow(k, f))}
812 </Box>
813 )
814 })}
815 {sorted.length > FILES_CAP && showAllToggle(k, 'files-all', view.expanded.files, sorted.length, () => act.toggleExpanded('files'))}
816 {view.expanded.files && sorted.length > MAX_FILES && T(k, `+${sorted.length - MAX_FILES}`, { dim: true })}
817 </Box>
818 )
819}
820
821const fileRow = (k: Kit, file: GitFile): unknown => {
822 const { Box } = k
823 const g = glyphOf(file)
824 // Un dossier non suivi finit par « / » : son nom est le dernier segment, barre comprise.
825 const cut = file.path.replace(/\/$/, '').lastIndexOf('/')
826 const partial = file.kind === 'changed' && file.index !== '.' && file.work !== '.'
827 return (
828 <Box key={`f-${file.path}`} flexDirection="row">
829 <Box width={2} flexShrink={0}>
830 {T(k, g.letter, { color: g.color, bold: true })}
831 </Box>
832 <Box flexGrow={1} flexShrink={1}>
833 {TT(k, [T(k, file.path.slice(0, cut + 1), { dim: true }), file.path.slice(cut + 1), file.from ? T(k, ` ← ${file.from}`, { dim: true }) : ''], { wrap: 'truncate-start' })}
834 </Box>
835 <Box flexShrink={0} marginLeft={1}>
836 {line(k, [
837 partial && T(k, `en${NB}partie${NB}indexé`, { dim: true }),
838 file.added !== null && T(k, `+${file.added}`, { color: C.green }),
839 file.removed !== null && T(k, `−${file.removed}`, { color: C.red }),
840 ])}
841 </Box>
842 </Box>
843 )
844}
845
846// ─── Branches ─────────────────────────────────────────────────────────────
847
848const branchesTab = (k: Kit, view: View, r: GitRepo, act: Actions): unknown => {
849 const { Box, Button, Input } = k
850 const f = view.form
851 const pending = view.switching
852 const q = f.filter.trim().toLowerCase()
853 const locals = r.branches.filter((b) => !q || b.name.toLowerCase().includes(q))
854 const remotes = r.remotes.filter((b) => !q || b.name.toLowerCase().includes(q))
855 const showRemotes = view.showRemotes || Boolean(q)
856 // Chaque liste a sa bascule « tout voir » ; un filtre montre tout.
857 const shownLocals = view.expanded.branches || q ? locals : locals.slice(0, BRANCHES_CAP)
858 const shownRemotes = view.expanded.remotes || q ? remotes : remotes.slice(0, BRANCHES_CAP)
859 return (
860 <Box key="branches" flexDirection="column" marginTop={1}>
861 {pending && confirmCard(k, view, r, pending, act)}
862 {Input ? (
863 <Box flexDirection="column" marginTop={pending ? 1 : 0}>
864 <Input
865 key="new-branch"
866 label="Nouvelle branche"
867 placeholder="feat/nom-court"
868 value={f.newBranch}
869 submitLabel="créer et basculer"
870 onInput={(v: string) => act.setForm({ newBranch: v })}
871 onSubmit={(v: string) => act.createBranch(v)}
872 />
873 {r.branches.length + r.remotes.length > 6 && (
874 <Box marginTop={1}>
875 <Input key="filter" label="Filtrer" placeholder="nom de branche" value={f.filter} onInput={(v: string) => act.setForm({ filter: v })} onSubmit={(v: string) => act.setForm({ filter: v })} />
876 </Box>
877 )}
878 </Box>
879 ) : (
880 unavailable(k, 'Création et filtre de branches indisponibles ici (pas de champ de saisie).', pending ? 1 : 0)
881 )}
882 <Box key="locals" flexDirection="column" borderStyle="round" borderDimColor={true} paddingX={1} marginTop={1}>
883 {line(k, [T(k, 'Locales', { bold: true }), T(k, String(locals.length), { dim: true })])}
884 {shownLocals.map((b) => branchRow(k, view, r, b, act))}
885 {q && !locals.length && !remotes.length && T(k, 'Aucune branche ne correspond.', { dim: true })}
886 {!q && locals.length > BRANCHES_CAP && showAllToggle(k, 'branches-all', view.expanded.branches, locals.length, () => act.toggleExpanded('branches'))}
887 </Box>
888 {remotes.length > 0 && (
889 <Box key="remotes" flexDirection="column" marginTop={1}>
890 <Button
891 key="remotes-toggle"
892 plain={true}
893 dimColor={true}
894 label={`${showRemotes ? '▾' : '▸'}${NB}Seulement${NB}sur${NB}le${NB}distant${NB}·${NB}${remotes.length}`}
895 onPress={() => act.toggleRemotes()}
896 />
897 {showRemotes && shownRemotes.map((b) => remoteRow(k, view, b, act))}
898 {showRemotes && !q && remotes.length > BRANCHES_CAP && showAllToggle(k, 'remotes-all', Boolean(view.expanded.remotes), remotes.length, () => act.toggleExpanded('remotes'))}
899 </Box>
900 )}
901 </Box>
902 )
903}
904
905const branchRow = (k: Kit, view: View, r: GitRepo, b: GitBranch, act: Actions): unknown => {
906 const { Box, Button } = k
907 const track: [string, string] = b.worktree
908 ? ['ouverte dans un autre worktree', C.gray]
909 : b.isGone
910 ? [`distante${NB}supprimée`, C.red]
911 : !b.upstream
912 ? ['locale', C.gray]
913 : b.ahead || b.behind
914 ? [[b.ahead ? `↑${b.ahead}` : '', b.behind ? `↓${b.behind}` : ''].filter(Boolean).join(NB), b.behind ? C.amber : C.green]
915 : [`✓${NB}à${NB}jour`, C.green]
916 return (
917 <Box key={`b-${b.name}`} flexDirection="row" alignItems="center">
918 <Box width={2} flexShrink={0}>
919 {T(k, b.isCurrent ? '●' : '○', { color: b.isCurrent ? BRAND : C.gray, bold: b.isCurrent })}
920 </Box>
921 <Box flexGrow={1} flexShrink={1}>
922 {TT(k, [T(k, b.name, { bold: b.isCurrent }), b.name === r.defaultBranch ? T(k, `${NB}${NB}défaut`, { dim: true }) : ''], { wrap: 'truncate-end' })}
923 </Box>
924 <Box flexShrink={0} marginLeft={1}>
925 {line(k, [
926 T(k, track[0], { color: track[1], dim: track[1] === C.gray }),
927 k.width >= 64 && T(k, ago(b.date, view.now), { dim: true }),
928 !b.isCurrent && !b.worktree && <Button key={`sw-${b.name}`} plain={true} dimColor={true} label={`↪${NB}basculer`} onPress={() => act.askSwitch(b.name, false)} />,
929 ])}
930 </Box>
931 </Box>
932 )
933}
934
935const remoteRow = (k: Kit, view: View, b: GitRemoteBranch, act: Actions): unknown => {
936 const { Box, Button } = k
937 return (
938 <Box key={`rb-${b.name}`} flexDirection="row" alignItems="center">
939 <Box width={2} flexShrink={0}>
940 {T(k, '☁', { color: C.blue })}
941 </Box>
942 <Box flexGrow={1} flexShrink={1}>
943 {T(k, b.name, { wrap: 'truncate-end' })}
944 </Box>
945 <Box flexShrink={0} marginLeft={1}>
946 {line(k, [k.width >= 64 && T(k, ago(b.date, view.now), { dim: true }), <Button key={`swr-${b.name}`} plain={true} dimColor={true} label={`↪${NB}basculer`} onPress={() => act.askSwitch(b.name, true)} />])}
947 </Box>
948 </Box>
949 )
950}
951
952const confirmCard = (k: Kit, view: View, r: GitRepo, s: NonNullable<Switching>, act: Actions): unknown => {
953 const { Box, Button } = k
954 const target = s.isRemote ? localOf(s.name) : s.name
955 const changed = countsOf(r).changed + countsOf(r).conflicts
956 return (
957 <Box key="confirm" flexDirection="column" borderStyle="round" borderColor={C.amber} paddingX={1}>
958 {T(k, `Basculer vers ${target} ?`, { color: C.amber, bold: true })}
959 {T(k, `${plural(changed, 'fichier modifié', 'fichiers modifiés')} suivr${changed > 1 ? 'ont' : 'a'} ; git refuse si l'un entre en conflit. Pour les garder ici, commite d'abord.`, { dim: true, wrap: 'wrap' })}
960 <Box marginTop={1}>
961 {line(k, [
962 <Button key="confirm-yes" variant="primary" label={`↪${NB}Basculer${NB}quand${NB}même`} onPress={() => act.confirmSwitch(s.name, s.isRemote)} />,
963 <Button key="confirm-no" label="Annuler" onPress={() => act.cancelSwitch()} />,
964 ])}
965 </Box>
966 </Box>
967 )
968}
969
970// ─── Historique ───────────────────────────────────────────────────────────
971
972const historyTab = (k: Kit, view: View, r: GitRepo, act: Actions): unknown => {
973 const { Box } = k
974 if (!r.commits.length) {
975 return (
976 <Box key="no-commits" flexDirection="column" borderStyle="round" borderDimColor={true} paddingX={1} marginTop={1}>
977 {T(k, "Aucun commit pour l'instant.", { bold: true })}
978 {T(k, 'Les commits de la branche apparaîtront ici.', { dim: true })}
979 </Box>
980 )
981 }
982 const unpushed = r.hasRemote ? r.unpushed : 0
983 const shown = view.expanded.commits ? r.commits : r.commits.slice(0, COMMITS_CAP)
984 return (
985 <Box key="history" flexDirection="column" borderStyle="round" borderDimColor={true} paddingX={1} marginTop={1}>
986 {line(k, [
987 T(k, 'Historique', { bold: true }),
988 T(k, `${r.commits.length}${NB}derniers`, { dim: true }),
989 unpushed > 0 && T(k, `↑${NB}${unpushed >= 500 ? '500+' : unpushed}${NB}pas${NB}encore${NB}poussé${unpushed > 1 ? 's' : ''}`, { color: C.green, bold: true }),
990 ])}
991 {shown.map((c) => commitRow(k, view, r, c))}
992 {r.commits.length > COMMITS_CAP && showAllToggle(k, 'commits-all', view.expanded.commits, r.commits.length, () => act.toggleExpanded('commits'))}
993 </Box>
994 )
995}
996
997const commitRow = (k: Kit, view: View, r: GitRepo, c: GitCommit): unknown => {
998 const { Box, Link } = k
999 const isLocal = r.hasRemote && !c.isPushed
1000 const tags = c.refs
1001 .split(', ')
1002 .filter((x) => x.startsWith('tag: '))
1003 .map((x) => `◆${NB}${x.slice(5)}`)
1004 return (
1005 <Box key={`c-${c.hash}`} flexDirection="row">
1006 <Box width={2} flexShrink={0}>
1007 {T(k, isLocal ? '↑' : '○', { color: isLocal ? C.green : C.gray, bold: isLocal })}
1008 </Box>
1009 <Box flexGrow={1} flexShrink={1}>
1010 {T(k, c.subject, { wrap: 'truncate-end' })}
1011 </Box>
1012 <Box flexShrink={0} marginLeft={1}>
1013 {line(k, [
1014 ...tags.map((t) => T(k, t, { color: C.violet })),
1015 T(k, c.short, { dim: true }),
1016 k.width >= 90 && T(k, c.author, { dim: true }),
1017 k.width >= 64 && T(k, ago(c.date, view.now), { dim: true }),
1018 r.webUrl && Link && <Link href={`${r.webUrl}/commit/${c.hash}`} label="↗" />,
1019 ])}
1020 </Box>
1021 </Box>
1022 )
1023}
1024
1025// ─── Bandeau ──────────────────────────────────────────────────────────────
1026
1027
1028
1029
1030/** Largeur du nom dans la colonne d'étiquette, la même dans les bandeaux Git, Flow et Asana. */
1031const LABEL_CELLS = 5
1032/** La colonne d'étiquette : la barre, une espace, le nom calé sur LABEL_CELLS. */
1033export const TAG_CELLS = 2 + LABEL_CELLS
1034/** La couleur de Git dans les bandeaux (mandarine, comme son logo) ; le violet reste celui de Flow. */
1035const TAG_COLOR = C.tangerine
1036
1037/**
1038 * L'étiquette d'outil en tête du bandeau : une barre verticale et le nom du mod en gras, de la
1039 * couleur du mod. Les bandeaux empilés s'alignent sur cette colonne et se distinguent par sa
1040 * couleur, sans trait horizontal (un « ─ » répété n'a pas la même largeur sur le desktop que
1041 * sur le terminal). `label` vide : la barre et la colonne vide, pour la ligne 2 ; `null` : la
1042 * barre seule, sur un bandeau trop étroit.
1043 */
1044const bandTag = (k: Kit, label: string | null, color: string): unknown => (
1045 <k.Box flexDirection="row" flexShrink={0}>
1046 {T(k, '▍', { color, bold: true })}
1047 {label !== null && T(k, `${NB}${label.padEnd(LABEL_CELLS, NB)}`, { color, bold: true })}
1048 </k.Box>
1049)
1050
1051/**
1052 * Le bandeau au-dessus du prompt : une ligne, deux au plus. Rien hors d'un dépôt ni quand
1053 * il est masqué (le hook rend alors le bandeau du dessous).
1054 */
1055export const drawBand = (k: Kit, view: View, act: Actions): unknown => {
1056 const r = view.repo
1057 if (!r || view.isBandHidden) return null
1058 const { Box, Button } = k
1059 const width = k.width
1060 const m = view.meta
1061 const n = countsOf(r)
1062 const pr = prOfBranch(r, view.pr)
1063 const watch = ciOf(r, view.ci)
1064 const isElsewhere = m.sessionRoot === null || !sameDir(m.sessionRoot, r.root)
1065 const fetched = fetchedText(m, view.now, r.hasRemote)
1066
1067 const remote = r.upstream
1068 ? r.ahead || r.behind
1069 ? line(k, [r.ahead > 0 && T(k, `↑${r.ahead}`, { color: C.green, bold: true }), r.behind > 0 && T(k, `↓${r.behind}`, { color: C.amber, bold: true })])
1070 : T(k, `✓${NB}à${NB}jour`, { color: C.green })
1071 : r.branch && r.hasRemote
1072 ? T(k, `◌${NB}non${NB}publiée`, { color: C.amber })
1073 : null
1074 const files = line(k, [
1075 n.conflicts > 0 && T(k, `✖${NB}${n.conflicts}`, { color: C.red, bold: true }),
1076 n.changed > 0 && T(k, `●${NB}${n.changed}`, { color: C.amber }),
1077 n.fresh > 0 && T(k, `+${NB}${n.fresh}`, { color: C.teal }),
1078 ])
1079 // Un « · » sépare les fichiers (« + 1 » nouveau) du total des lignes (« +13 »).
1080 const diff =
1081 width >= 72 &&
1082 n.added + n.removed > 0 &&
1083 line(k, [r.files.length > 0 && T(k, '·', { dim: true }), n.added > 0 && T(k, `+${n.added}`, { color: C.green, dim: true }), n.removed > 0 && T(k, `−${n.removed}`, { color: C.red, dim: true })])
1084 const prBit =
1085 width >= 72 &&
1086 pr &&
1087 line(k, [
1088 T(k, `⇄${NB}#${pr.number}`, { color: C.violet, bold: true }),
1089 T(k, prLabel(pr), { color: prColor(pr) }),
1090 ...(watch ? [ciText(watch, true)] : checksText(pr.checks)).map(([text, c]) => T(k, text, { color: c })),
1091 ])
1092 // Sans PR, la CI suivie après un push, à la place de la PR.
1093 const ciBit = width >= 72 && !pr && watch && T(k, ciText(watch, true)[0], { color: ciText(watch, true)[1] })
1094 const fetchBit = fetched && (fetched.isError ? width >= 64 && T(k, fetched.text, { color: C.red }) : width >= 90 && T(k, fetched.text, { dim: true }))
1095
1096 const run = view.running
1097 const second =
1098 width < 56
1099 ? null
1100 : r.operation
1101 ? T(k, `${OPERATION[r.operation] ?? r.operation} en cours${n.conflicts ? `${SEP}${plural(n.conflicts, 'conflit')}` : ''}`, { color: C.red, bold: true, wrap: 'truncate-end' })
1102 : run
1103 ? line(k, [
1104 // Rien ne tourne tant que la commande attend son tour : « ▶ », pas de spinner.
1105 run.isStarted ? spinner(k, C.tangerine, view.now, 'commande en cours') : T(k, '▶', { color: C.tangerine }),
1106 T(k, run.isStarted ? `${run.command} en cours` : `${run.command} en file`, { color: C.tangerine, wrap: 'truncate-end' }),
1107 ])
1108 : view.busy
1109 ? line(k, [spinner(k, C.tangerine, view.now, 'git au travail'), T(k, view.busy, { color: C.tangerine, wrap: 'truncate-end' })])
1110 : null
1111
1112 return (
1113 <Box key="git-band" flexDirection="column" width={width}>
1114 <Box flexDirection="row" alignItems="center" justifyContent="space-between">
1115 <Box flexShrink={1}>
1116 {line(k, [
1117 bandTag(k, 'Git', TAG_COLOR),
1118 T(k, '⎇', { color: r.operation || n.conflicts ? C.red : BRAND, bold: true }),
1119 <Box flexShrink={1}>{T(k, branchLabel(r), { bold: true, wrap: 'truncate-end' })}</Box>,
1120 width >= 90 && isElsewhere && T(k, r.name, { dim: true }),
1121 remote,
1122 r.files.length > 0 && files,
1123 diff,
1124 prBit,
1125 ciBit,
1126 fetchBit,
1127 ])}
1128 </Box>
1129 <Box flexDirection="row" flexShrink={0} marginLeft={1}>
1130 <Box marginRight={1}>
1131 <Button key="git-band-open" plain={true} dimColor={true} label={`▸${NB}/git`} onPress={() => act.openPane()} />
1132 </Box>
1133 {/* Pas de role="dismiss" : plusieurs bandeaux empilés auraient chacun leur fermeture native au même endroit. */}
1134 <Button key="git-band-hide" plain={true} dimColor={true} label="x" onPress={() => act.hideBand()} />
1135 </Box>
1136 </Box>
1137 {second && (
1138 <Box flexDirection="row">
1139 {bandTag(k, '', TAG_COLOR)}
1140 <Box flexShrink={1} marginLeft={1}>
1141 {second}
1142 </Box>
1143 </Box>
1144 )}
1145 </Box>
1146 )
1147}
1148hooks/svg.ts 326 lines1// Dessins SVG des surfaces distantes (desktop, VS Code, mobile). Copie des helpers de
2// flow-board (hooks/svg.ts : feuille de route, anneau, barre segmentée, points, indicateurs
3// vivants), plus la jauge ↑/↓ propre à git-board en fin de fichier.
4//
5// - Les dessins sont des images (jamais `isInteractive`, qui dessine dans un cadre à fond
6// blanc rechargé à chaque redessin) : les animations SMIL y sont probablement figées.
7// - Rien qui change chaque seconde dans un SVG (les âges restent en `Text`), pour qu'un SVG
8// identique ne soit pas rechargé à chaque redessin.
9// - Couleurs mi-tons et gris neutre : lisibles sur fond clair comme sombre.
10
11const FONT = `font-family="'Segoe UI', system-ui, -apple-system, sans-serif"`
12const MUTED = '#8B8B93'
13const WHITE = '#FFFFFF'
14
15export const esc = (text: string): string =>
16 text.replace(/[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[c] ?? c)
17
18const cut = (text: string, max: number): string =>
19 max <= 1 ? '' : text.length <= max ? text : `${text.slice(0, Math.max(1, max - 1))}…`
20
21const svg = (width: number, height: number, body: string): string =>
22 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${body}</svg>`
23
24/** Coupe un libellé en deux lignes au plus, aux espaces ; la seconde se termine par « … » s'il en reste. */
25const wrap2 = (text: string, max: number): string[] => {
26 if (text.length <= max) return [text]
27 const words = text.split(/\s+/)
28 let first = ''
29 let i = 0
30 while (i < words.length) {
31 const next = first ? `${first} ${words[i]}` : words[i]!
32 if (next.length > max && first) break
33 first = next
34 i += 1
35 }
36 const rest = words.slice(i).join(' ')
37 return rest ? [cut(first, max), cut(rest, max)] : [cut(first, max)]
38}
39
40/** Largeur moyenne d'un caractère à 11,5 px (Segoe UI, system-ui). */
41const CHAR_PX = 6.2
42
43const r1 = (n: number): number => Math.round(n * 10) / 10
44
45export type NodeState = 'done' | 'active' | 'next' | 'pending' | 'skipped'
46
47export type RoadNode = {
48 label: string
49 sub?: string
50 color: string
51 state: NodeState
52 isOptional?: boolean
53 detours?: { label: string; color: string; isActive: boolean }[]
54}
55
56export type Drawn = { source: string; width: number; height: number }
57
58const check = (x: number, y: number, size: number, color: string): string =>
59 `<path d="M${r1(x - size * 0.45)} ${r1(y + size * 0.02)} l${r1(size * 0.32)} ${r1(size * 0.32)} l${r1(size * 0.6)} ${r1(-size * 0.62)}" fill="none" stroke="${color}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>`
60
61/**
62 * La feuille de route : une station par phase (ou par étape pour un parcours d'une
63 * phase), reliées par une ligne. Station faite pleine et cochée, station en cours
64 * pulsante, segment en cours animé vers la suite, suites facultatives en pointillé,
65 * détours en bulles au-dessus de leur station.
66 */
67export const roadmapSvg = (nodes: readonly RoadNode[], width: number): Drawn => {
68 const n = nodes.length
69 const w = Math.max(200, Math.round(width))
70 const hasDetours = nodes.some(x => (x.detours?.length ?? 0) > 0)
71 const top = hasDetours ? 34 : 8
72 const cy = top + 14
73 const pad = n > 1 ? Math.max(34, Math.min(64, w / (n * 2))) : w / 2
74 const xs = nodes.map((_, i) => (n > 1 ? pad + (i * (w - 2 * pad)) / (n - 1) : w / 2))
75 const slot = n > 1 ? (w - 2 * pad) / (n - 1) : w
76 const charsAt = (i: number): number => {
77 const isEdge = n > 1 && (i === 0 || i === n - 1)
78 const room = n > 1 ? (isEdge ? Math.min(slot, pad + slot / 2) : slot) : Math.min(w, 260)
79 return Math.max(3, Math.floor((room - 6) / CHAR_PX))
80 }
81 const labels = nodes.map((node, i) => wrap2(node.label, charsAt(i)))
82 const isTall = labels.some(l => l.length > 1)
83 const subY = cy + (isTall ? 58 : 45)
84 const height = cy + (isTall ? 64 : 50)
85 const defs: string[] = []
86 const lines: string[] = []
87 const marks: string[] = []
88 const texts: string[] = []
89 const bubbles: string[] = []
90
91 for (let i = 0; i < n - 1; i++) {
92 const a = nodes[i]!
93 const b = nodes[i + 1]!
94 const x1 = r1(xs[i]! + 15)
95 const x2 = r1(xs[i + 1]! - 15)
96 if (x2 <= x1) continue
97 const isDone = (a.state === 'done' || a.state === 'skipped') && (b.state === 'done' || b.state === 'active' || b.state === 'skipped')
98 if (isDone) {
99 defs.push(
100 `<linearGradient id="g${i}" gradientUnits="userSpaceOnUse" x1="${x1}" y1="0" x2="${x2}" y2="0"><stop offset="0" stop-color="${a.color}"/><stop offset="1" stop-color="${b.color}"/></linearGradient>`,
101 )
102 lines.push(`<line x1="${x1}" y1="${cy}" x2="${x2}" y2="${cy}" stroke="url(#g${i})" stroke-width="3" stroke-linecap="round"/>`)
103 } else if (a.state === 'active' || (a.state === 'done' && b.state === 'next')) {
104 lines.push(
105 `<line x1="${x1}" y1="${cy}" x2="${x2}" y2="${cy}" stroke="${a.color}" stroke-opacity="0.8" stroke-width="2.5" stroke-linecap="round" stroke-dasharray="6 7"><animate attributeName="stroke-dashoffset" from="13" to="0" dur="0.9s" repeatCount="indefinite"/></line>`,
106 )
107 } else {
108 lines.push(
109 `<line x1="${x1}" y1="${cy}" x2="${x2}" y2="${cy}" stroke="${MUTED}" stroke-opacity="0.5" stroke-width="2" stroke-linecap="round" stroke-dasharray="2 6"/>`,
110 )
111 }
112 }
113
114 nodes.forEach((node, i) => {
115 const x = r1(xs[i]!)
116 const c = node.color
117 switch (node.state) {
118 case 'done':
119 marks.push(`<circle cx="${x}" cy="${cy}" r="11" fill="${c}"/>${check(x, cy, 10, WHITE)}`)
120 break
121 case 'active':
122 marks.push(
123 `<circle cx="${x}" cy="${cy}" r="12" fill="${c}" opacity="0.4"><animate attributeName="r" values="12;21" dur="1.8s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.45;0" dur="1.8s" repeatCount="indefinite"/></circle>` +
124 `<circle cx="${x}" cy="${cy}" r="12" fill="${c}"/><circle cx="${x}" cy="${cy}" r="4.5" fill="${WHITE}"/>`,
125 )
126 break
127 case 'next':
128 marks.push(
129 `<circle cx="${x}" cy="${cy}" r="10.5" fill="${c}" fill-opacity="0.16" stroke="${c}" stroke-width="2.4"><animate attributeName="stroke-opacity" values="1;0.35;1" dur="2.2s" repeatCount="indefinite"/></circle>` +
130 `<path d="M${r1(x - 3)} ${r1(cy - 4.5)} l7 4.5 l-7 4.5 z" fill="${c}"/>`,
131 )
132 break
133 case 'skipped':
134 marks.push(
135 `<circle cx="${x}" cy="${cy}" r="8" fill="none" stroke="${MUTED}" stroke-opacity="0.65" stroke-width="1.6" stroke-dasharray="2 3"/>` +
136 `<line x1="${x - 4}" y1="${cy + 4}" x2="${x + 4}" y2="${cy - 4}" stroke="${MUTED}" stroke-opacity="0.65" stroke-width="1.6" stroke-linecap="round"/>`,
137 )
138 break
139 default:
140 marks.push(
141 `<circle cx="${x}" cy="${cy}" r="9.5" fill="${c}" fill-opacity="0.08" stroke="${c}" stroke-opacity="${node.isOptional ? 0.5 : 0.7}" stroke-width="1.8"${node.isOptional ? ' stroke-dasharray="3 3"' : ''}/>` +
142 `<text x="${x}" y="${r1(cy + 3.5)}" text-anchor="middle" ${FONT} font-size="10" font-weight="700" fill="${c}" fill-opacity="0.85">${i + 1}</text>`,
143 )
144 }
145
146 // Libellés : centrés, sauf aux bords où ils s'alignent sur la station pour rester entiers.
147 const isEdgeLeft = n > 1 && i === 0
148 const isEdgeRight = n > 1 && i === n - 1
149 const chars = charsAt(i)
150 const anchor = isEdgeLeft && pad < 46 ? 'start' : isEdgeRight && pad < 46 ? 'end' : 'middle'
151 const tx = anchor === 'start' ? r1(x - 12) : anchor === 'end' ? r1(x + 12) : x
152 const isLit = node.state === 'active' || node.state === 'done' || node.state === 'next'
153 const weight = node.state === 'active' ? 700 : isLit ? 600 : 500
154 const lines = labels[i]!
155 texts.push(
156 `<text x="${tx}" y="${cy + 30}" text-anchor="${anchor}" ${FONT} font-size="11.5" font-weight="${weight}" fill="${isLit ? c : MUTED}"${node.state === 'skipped' ? ' fill-opacity="0.7"' : ''}>` +
157 lines.map((l, j) => `<tspan x="${tx}" dy="${j === 0 ? 0 : 13}">${esc(l)}</tspan>`).join('') +
158 '</text>',
159 )
160 if (node.sub) {
161 texts.push(
162 `<text x="${tx}" y="${subY}" text-anchor="${anchor}" ${FONT} font-size="10" font-weight="${node.state === 'active' ? 600 : 400}" fill="${node.state === 'active' ? c : MUTED}">${esc(cut(node.sub, chars + 2))}</text>`,
163 )
164 }
165
166 // Détours : la dernière bulle au-dessus de la station, avec le nombre des autres.
167 const detours = node.detours ?? []
168 if (detours.length > 0) {
169 const last = detours[detours.length - 1]!
170 const label = `↳ ${last.label}${detours.length > 1 ? ` +${detours.length - 1}` : ''}`
171 const shown = cut(label, Math.max(6, Math.floor((slot - 10) / 6)))
172 const bw = Math.round(shown.length * 6.1 + 16)
173 const bx = Math.min(Math.max(2, x - bw / 2), w - bw - 2)
174 const by = 3
175 bubbles.push(
176 `<line x1="${x}" y1="${by + 18}" x2="${x}" y2="${cy - 13}" stroke="${last.color}" stroke-opacity="0.55" stroke-width="1.4" stroke-dasharray="2 2"/>` +
177 `<rect x="${r1(bx)}" y="${by}" width="${bw}" height="18" rx="9" fill="${last.color}" fill-opacity="0.14" stroke="${last.color}" stroke-opacity="0.75" stroke-width="1.2">${last.isActive ? '<animate attributeName="stroke-opacity" values="0.9;0.3;0.9" dur="1.6s" repeatCount="indefinite"/>' : ''}</rect>` +
178 `<text x="${r1(bx + bw / 2)}" y="${by + 12.5}" text-anchor="middle" ${FONT} font-size="10" font-weight="600" fill="${last.color}">${esc(shown)}</text>`,
179 )
180 }
181 })
182
183 const body = `${defs.length > 0 ? `<defs>${defs.join('')}</defs>` : ''}${lines.join('')}${bubbles.join('')}${marks.join('')}${texts.join('')}`
184 return { source: svg(w, height, body), width: w, height }
185}
186
187/** Anneau de progression, le pourcentage au centre. */
188export const ringSvg = (ratio: number, color: string, size = 60): Drawn => {
189 const r = size / 2 - 6
190 const c = 2 * Math.PI * r
191 const value = Math.min(1, Math.max(0, ratio))
192 const len = r1(c * value)
193 const mid = size / 2
194 const body =
195 `<circle cx="${mid}" cy="${mid}" r="${r1(r)}" fill="none" stroke="${MUTED}" stroke-opacity="0.22" stroke-width="7"/>` +
196 (value > 0
197 ? `<circle cx="${mid}" cy="${mid}" r="${r1(r)}" fill="none" stroke="${color}" stroke-width="7" stroke-linecap="round" stroke-dasharray="${len} ${r1(c)}" transform="rotate(-90 ${mid} ${mid})"/>`
198 : '') +
199 `<text x="${mid}" y="${mid + 5}" text-anchor="middle" ${FONT} font-size="14" font-weight="700" fill="${color}">${Math.round(value * 100)}%</text>`
200 return { source: svg(size, size, body), width: size, height: size }
201}
202
203export type BarPart = { value: number; color: string; isLive?: boolean }
204
205/** Barre segmentée arrondie (fait, en cours, bloqué…), la part vivante qui respire. */
206export const barSvg = (parts: readonly BarPart[], total: number, width: number, height = 9): Drawn => {
207 const w = Math.max(60, Math.round(width))
208 const sum = Math.max(total, parts.reduce((n, p) => n + p.value, 0), 1)
209 let x = 0
210 const segs: string[] = []
211 for (const part of parts) {
212 if (part.value <= 0) continue
213 const sw = (part.value / sum) * w
214 segs.push(
215 `<rect x="${r1(x)}" y="0" width="${r1(sw)}" height="${height}" fill="${part.color}">${part.isLive ? '<animate attributeName="opacity" values="1;0.55;1" dur="1.6s" repeatCount="indefinite"/>' : ''}</rect>`,
216 )
217 x += sw
218 }
219 const body =
220 `<defs><clipPath id="bar"><rect width="${w}" height="${height}" rx="${height / 2}"/></clipPath></defs>` +
221 `<g clip-path="url(#bar)"><rect width="${w}" height="${height}" fill="${MUTED}" fill-opacity="0.22"/>${segs.join('')}</g>`
222 return { source: svg(w, height, body), width: w, height }
223}
224
225/** Les derniers lancements de tests : un point vert ou rouge chacun, le dernier qui pulse. */
226export const dotsSvg = (runs: readonly { ok: boolean }[], width: number, green: string, red: string): Drawn => {
227 const step = 12
228 const max = Math.max(1, Math.floor((Math.max(80, width) - 10) / step))
229 const shown = runs.slice(-max)
230 const w = Math.max(20, shown.length * step + 8)
231 const h = 16
232 const dots = shown.map((run, i) => {
233 const x = 8 + i * step
234 const c = run.ok ? green : red
235 const isLast = i === shown.length - 1
236 return (
237 (isLast
238 ? `<circle cx="${x}" cy="8" r="4" fill="${c}" opacity="0.4"><animate attributeName="r" values="4;8" dur="1.8s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.45;0" dur="1.8s" repeatCount="indefinite"/></circle>`
239 : '') + `<circle cx="${x}" cy="8" r="${isLast ? 4.6 : 3.8}" fill="${c}"/>`
240 )
241 })
242 return { source: svg(w, h, dots.join('')), width: w, height: h }
243}
244
245/** Un arc qui tourne : un sous-agent au travail. */
246export const spinnerSvg = (color: string, size = 14): Drawn => {
247 const m = size / 2
248 const r = m - 2
249 const body =
250 `<circle cx="${m}" cy="${m}" r="${r}" fill="none" stroke="${color}" stroke-opacity="0.22" stroke-width="2"/>` +
251 `<circle cx="${m}" cy="${m}" r="${r}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-dasharray="${r1(r * 2)} ${r1(r * 5)}"><animateTransform attributeName="transform" type="rotate" from="0 ${m} ${m}" to="360 ${m} ${m}" dur="0.9s" repeatCount="indefinite"/></circle>`
252 return { source: svg(size, size, body), width: size, height: size }
253}
254
255/** Point d'état ; pulsant quand quelque chose tourne. */
256export const pulseSvg = (color: string, isPulsing: boolean, size = 12): Drawn => {
257 const m = size / 2
258 const body =
259 (isPulsing
260 ? `<circle cx="${m}" cy="${m}" r="3" fill="${color}" opacity="0.5"><animate attributeName="r" values="3;${m}" dur="1.4s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.55;0" dur="1.4s" repeatCount="indefinite"/></circle>`
261 : '') + `<circle cx="${m}" cy="${m}" r="3.4" fill="${color}"/>`
262 return { source: svg(size, size, body), width: size, height: size }
263}
264
265/** La feuille de route du bandeau : des pastilles, sans libellés. */
266export const miniRoadSvg = (nodes: readonly { color: string; state: NodeState; isOptional?: boolean }[]): Drawn => {
267 const gap = 17
268 const h = 16
269 const w = Math.max(16, (nodes.length - 1) * gap + 16)
270 const cy = h / 2
271 const parts: string[] = []
272 nodes.forEach((node, i) => {
273 if (i === nodes.length - 1) return
274 const next = nodes[i + 1]!
275 const x1 = 8 + i * gap + 6
276 const x2 = 8 + (i + 1) * gap - 6
277 const isDone = node.state === 'done' || node.state === 'skipped'
278 parts.push(
279 isDone && next.state !== 'pending' && next.state !== 'next'
280 ? `<line x1="${x1}" y1="${cy}" x2="${x2}" y2="${cy}" stroke="${node.color}" stroke-width="2" stroke-linecap="round"/>`
281 : `<line x1="${x1}" y1="${cy}" x2="${x2}" y2="${cy}" stroke="${MUTED}" stroke-opacity="0.5" stroke-width="1.6" stroke-dasharray="2 3"/>`,
282 )
283 })
284 nodes.forEach((node, i) => {
285 const x = 8 + i * gap
286 const c = node.color
287 if (node.state === 'done') parts.push(`<circle cx="${x}" cy="${cy}" r="4.6" fill="${c}"/>`)
288 else if (node.state === 'active')
289 parts.push(
290 `<circle cx="${x}" cy="${cy}" r="5" fill="${c}" opacity="0.4"><animate attributeName="r" values="5;8" dur="1.6s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.5;0" dur="1.6s" repeatCount="indefinite"/></circle><circle cx="${x}" cy="${cy}" r="5.2" fill="${c}"/><circle cx="${x}" cy="${cy}" r="2" fill="${WHITE}"/>`,
291 )
292 else if (node.state === 'next') parts.push(`<circle cx="${x}" cy="${cy}" r="4.4" fill="none" stroke="${c}" stroke-width="2"/>`)
293 else if (node.state === 'skipped') parts.push(`<circle cx="${x}" cy="${cy}" r="3" fill="${MUTED}" fill-opacity="0.5"/>`)
294 else
295 parts.push(
296 `<circle cx="${x}" cy="${cy}" r="4.2" fill="none" stroke="${c}" stroke-opacity="${node.isOptional ? 0.45 : 0.7}" stroke-width="1.5"${node.isOptional ? ' stroke-dasharray="2 2"' : ''}/>`,
297 )
298 })
299 return { source: svg(w, h, parts.join('')), width: w, height: h }
300}
301
302// ─── Propre à git-board ─────────────────────────────────────────────────────
303
304/**
305 * Jauge de l'écart avec le distant : à gauche les commits à récupérer (↓), à droite ceux
306 * à pousser (↑), de part et d'autre d'un trait central. Les chiffres sont dans le dessin :
307 * ils ne changent qu'à une relecture du dépôt, jamais à chaque seconde.
308 */
309export const gaugeSvg = (ahead: number, behind: number, up: string, down: string, size = 64): Drawn => {
310 const w = size * 2
311 const h = 34
312 const mid = w / 2
313 const half = mid - 12
314 const scale = (n: number): number => (n <= 0 ? 0 : r1(Math.max(6, Math.min(half, (Math.log2(n + 1) / Math.log2(33)) * half))))
315 const left = scale(behind)
316 const right = scale(ahead)
317 const body =
318 `<rect x="4" y="8" width="${w - 8}" height="8" rx="4" fill="${MUTED}" fill-opacity="0.18"/>` +
319 (left > 0 ? `<rect x="${r1(mid - left)}" y="8" width="${left}" height="8" rx="4" fill="${down}"/>` : '') +
320 (right > 0 ? `<rect x="${mid}" y="8" width="${right}" height="8" rx="4" fill="${up}"/>` : '') +
321 `<line x1="${mid}" y1="4" x2="${mid}" y2="20" stroke="${MUTED}" stroke-width="1.6" stroke-linecap="round"/>` +
322 `<text x="${r1(mid - 6)}" y="31" text-anchor="end" ${FONT} font-size="11" font-weight="700" fill="${behind > 0 ? down : MUTED}">↓${behind}</text>` +
323 `<text x="${r1(mid + 6)}" y="31" text-anchor="start" ${FONT} font-size="11" font-weight="700" fill="${ahead > 0 ? up : MUTED}">↑${ahead}</text>`
324 return { source: svg(w, h, body), width: w, height: h }
325}
326types/index.d.ts 180 lines1/** Un fichier modifié, tel que `git status --porcelain=v2` le décrit. */
2export type GitFile = {
3 path: string
4 /** Ancien chemin d'un renommage ou d'une copie. */
5 from: string | null
6 /** Lettre côté index (`M`, `A`, `D`, `R`…), `.` quand rien n'est indexé. */
7 index: string
8 /** Lettre côté arbre de travail, `.` quand rien n'a changé. */
9 work: string
10 kind: 'conflict' | 'changed' | 'untracked'
11 /** Lignes ajoutées et retirées depuis HEAD ; null pour un binaire ou un fichier non suivi. */
12 added: number | null
13 removed: number | null
14}
15
16export type GitBranch = {
17 name: string
18 isCurrent: boolean
19 upstream: string | null
20 ahead: number
21 behind: number
22 /** La branche distante suivie a été supprimée. */
23 isGone: boolean
24 /** Dossier d'un autre worktree où la branche est extraite : git refuse d'y basculer ici. */
25 worktree: string | null
26 /** Date du dernier commit, en ms. */
27 date: number
28 subject: string
29}
30
31/** Une branche distante sans branche locale du même nom. */
32export type GitRemoteBranch = {
33 /** `origin/feat/x` */
34 name: string
35 /** `feat/x`, la branche locale que la bascule crée. */
36 local: string
37 date: number
38 subject: string
39}
40
41export type GitCommit = {
42 hash: string
43 short: string
44 author: string
45 date: number
46 /** Décorations de `git log` (`tag: v1.2.0`, `origin/main`…). */
47 refs: string
48 subject: string
49 /** Présent sur au moins une branche distante. */
50 isPushed: boolean
51}
52
53export type GitRepo = {
54 root: string
55 name: string
56 /** null quand HEAD est détachée. */
57 branch: string | null
58 head: string | null
59 /** HEAD en entier (40 caractères) : le commit dont la CI est suivie après un push. */
60 oid: string | null
61 upstream: string | null
62 ahead: number
63 behind: number
64 defaultBranch: string | null
65 /** `https://github.com/<owner>/<repo>` quand le distant est sur GitHub. */
66 webUrl: string | null
67 hasRemote: boolean
68 /** Opération en cours : rebase, merge, cherry-pick ou revert. */
69 operation: string | null
70 stash: number
71 /** Commits de HEAD absents de tout distant (`git rev-list -n 500 HEAD --not --remotes`). */
72 unpushed: number
73 files: GitFile[]
74 branches: GitBranch[]
75 remotes: GitRemoteBranch[]
76 commits: GitCommit[]
77}
78
79export type PrChecks = {
80 passed: number
81 failed: number
82 pending: number
83 /** Nom du premier check en échec (`name` d'un CheckRun, `context` d'un StatusContext). */
84 firstFailed?: string | null
85}
86
87/**
88 * Suivi de la CI après un push, sur le commit poussé (`sha`), jusqu'à ce que tous les checks
89 * soient finis : par la PR (`statusCheckRollup`) quand il y en a une, sinon par les
90 * exécutions GitHub Actions du commit (`gh run list --commit`).
91 * - `waiting` : rien encore lancé ; `running` : des checks tournent ;
92 * - `passed`, `failed` : tous finis ; `timeout` : plafond atteint, suivi arrêté ;
93 * - `none` : toujours aucun check au bout du délai d'attente, suivi arrêté.
94 */
95export type CiWatch = {
96 root: string
97 branch: string
98 sha: string
99 startedAt: number
100 phase: 'waiting' | 'running' | 'passed' | 'failed' | 'timeout' | 'none'
101 checks: PrChecks
102} | null
103
104export type PullRequest = {
105 number: number
106 title: string
107 url: string
108 state: 'OPEN' | 'MERGED' | 'CLOSED'
109 isDraft: boolean
110 base: string
111 /** Le commit de tête de la PR (`headRefOid`) ; null quand gh ne le donne pas. */
112 head: string | null
113 review: string | null
114 checks: PrChecks
115}
116
117/** La pull request de `branch` telle que `gh pr view` l'a rendue ; null tant que rien n'est lu. */
118export type PrState = { branch: string; pr: PullRequest | null; error: string | null } | null
119
120export type Meta = {
121 /** Dossier suivi ; null pour celui de la session. */
122 folder: string | null
123 /** Racine git du dossier de la session : là où agissent les commandes /project. */
124 sessionRoot: string | null
125 loading: string | null
126 /** Le dossier suivi n'est pas un dépôt git. */
127 error: string | null
128 fetchedAt: number | null
129 fetchError: string | null
130}
131
132export type Tab = 'changes' | 'branches' | 'history'
133export type Action = 'commit' | 'pull' | 'push' | 'pr'
134
135export type Notice = { text: string; tone: 'ok' | 'error' | 'info' } | null
136
137/** Champs du panneau gardés pendant la saisie. */
138export type Form = {
139 hint: string
140 pullFrom: 'upstream' | 'base'
141 prBase: string
142 prDraft: boolean
143 prAuto: boolean
144 filter: string
145 newBranch: string
146}
147
148/** Bascule en attente de confirmation : l'arbre de travail a des modifications. */
149export type Switching = { name: string; isRemote: boolean } | null
150
151/** Commande /project lancée depuis le panneau, jusqu'à la fin de son tour. */
152// `action`, `head` et `startedAt` disent ce qui marquera la fin de la commande dans le dépôt
153// relu (voir isRunDone) ; absents d'une commande lancée par une version précédente du mod.
154export type Running = { command: string; isStarted: boolean; action?: Action; head?: string | null; startedAt?: number } | null
155
156/** Listes du panneau dépliées au-delà de leur plafond (« ▸ tout voir (N) »). */
157export type Expanded = { files: boolean; branches: boolean; remotes: boolean; commits: boolean }
158
159declare module 'claude-code' {
160 interface PluginState {
161 'git-board': {
162 repo: GitRepo | null
163 pr: PrState
164 meta: Meta
165 tab: Tab
166 action: Action | null
167 form: Form
168 notice: Notice
169 busy: string | null
170 running: Running
171 switching: Switching
172 showRemotes: boolean
173 /** Bandeau au-dessus du prompt masqué (`x` ou `/git hide`), le temps de la session. */
174 isBandHidden: boolean
175 expanded: Expanded
176 ci: CiWatch
177 }
178 }
179}
180