SLOPSHOPPER

git-board

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

panebandguardcommandtoaststatus
v0.4.3MITupdated 2026-10-16matwi5/mathis-plugins/plugins/git-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-board
│ ┃ Git ✕ › fix the failing auth test and add an audit log call │ ┃ ╭────────────────────────────────────────── │ ┃ │ ⏺ Read(src/auth.ts) │ ┃ │ ◆ Pas de dépôt git ici ⎿ Read 6 lines │ ┃ │ Le panneau se remplit dès que le ⏺ Update(src/auth.ts) │ ┃ │ dossier suivi est dans un dépôt git. ⎿ Added 2 lines, removed 1 line │ ┃ │ ⏺ Bash(bun test) │ ┃ │ Dossier: chemin d'un dépôt git ⏎ ouvrir ⎿ 3 pass, 1 fail │ ┃ │ │ ┃ │ ou /git <dossier> ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ ┃ ╰────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ │ › /git │ ⎿ git-board: Panneau Git ouvert. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Git
╭────────────────────────────────────────────────────────╮ │ │ │ ◆ Pas de dépôt git ici │ │ Le panneau se remplit dès que le dossier suivi est │ │ dans un dépôt git. │ │ │ │ Dossier: chemin d'un dépôt git ⏎ ouvrir │ │ │ │ ou /git <dossier> │ │ │ ╰────────────────────────────────────────────────────────╯
README

git-board

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.

  • Il lit, il n'écrit pas. Le mod ne lance que des lectures git, git fetch et git switch.
  • Il délègue la livraison. Commit, pull, push et PR passent par les commandes du plugin project (/project:commit, /project:sync, /project:push, /project:pr).
  • Il suit le dépôt tout seul. Une relecture suit chaque outil d'écriture de Claude, et un coup d'œil léger repère toutes les 10 s les changements faits ailleurs. Après un push, il suit la CI jusqu'à la fin de ses checks.
  • Il propose le push. À la fin d'un tour, des commits à pousser sur un arbre propre : /project:push apparaît grisé dans le prompt, Tab pour le prendre.

Version actuelle : 0.4.3. Historique des versions : CHANGELOG.md.


Sommaire

  1. Installation
  2. Utilisation
  3. Le panneau
  4. Le bandeau
  5. Commandes
  6. Données et rafraîchissement
  7. Architecture
  8. Développement
  9. Limites connues

Installation

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

  • Claude Code avec les function hooks : mods de la version 2.1.286 ou plus récente.
  • git dans le PATH.
  • Facultatifs :
  • le plugin project, pour les boutons Commit, Pull, Push et PR ;
  • la CLI GitHub gh, pour lire la pull request et suivre la CI.

Utilisation

  • Dans un dépôt git, le bandeau apparaît au-dessus du prompt dès le démarrage de la session.
  • /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.


Le panneau

De haut en bas :

  1. La carte d'en-tête.
  2. Ligne 1 : la pastille de la branche (violette au repos, rouge pendant une opération ou un conflit, tronquée si le nom est long), la commande /project en file ou en cours, « ↩ session » quand un autre dossier est suivi, « ↻ Relire ».
  3. Ligne 2 : la jauge ↑/↓ (SVG, sur le desktop, VS Code et le mobile), puis sur une ligne le nom du dépôt, la branche suivie et l'âge du fetch. « fetch jamais » quand aucun n'est connu. Un fetch en échec s'affiche en rouge.
  4. Les pastilles d'état, par gravité : opération en cours, conflits, à récupérer et à pousser (sur le terminal ; ailleurs la jauge les montre), à jour ou non publiée, modifiés, nouveaux, stash.
  5. La pull request, sur une ligne : numéro, état, relecture, checks, titre, lien. Sans PR : « pas encore de pull request ». Pendant le suivi de la CI (voir Suivi de la CI), son état remplace les checks ; sans PR, il a sa propre ligne. Une PR fusionnée ou fermée ne compte que tant que HEAD est encore sa tête : une branche réutilisée après une fusion (dev, par exemple) repart à « PR à ouvrir » dès qu'elle a de nouveaux commits.
  6. La route de livraison : Commit → Push → PR → Checks → Fusion (SVG, ou glyphes reliés sur le terminal). Elle se parcourt dans l'ordre : dès qu'une étape n'est pas faite, les suivantes sont « à venir » (un commit à pousser rend caducs la PR, les checks et la fusion d'avant ; seul le numéro d'une PR encore ouverte reste). Sur le terminal étroit, les libellés qui ne tiennent pas prennent une forme courte (« Com. », « CI », « Fus. »).
  7. La zone d'activité, toujours sous l'en-tête : lecture en cours, bascule, commande /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 »).
  8. La carte d'opération (rebase, merge…), en rouge, quand il y en a une.
  9. La barre d'actions : ✓ Commit, ↓ Pull, ↑ Push, ⇄ Pull request, sans comptes (l'en-tête les dit). Seule l'étape suggérée est mise en avant, tant que son tiroir est fermé. Chacune ouvre un tiroir sobre avec « ▶ Lancer · /project:… » ; son « ✕ » ne ferme que le tiroir. Quand il y a quelque chose à pousser, le tiroir Push suggère de lancer d'abord /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.
  10. Les onglets, avec leurs comptes :
  11. Modifications : total +a −r lignes et barre de diff, puis les fichiers groupés (En conflit, Indexés, Modifiés, Nouveaux).
  12. Branches : création, filtre, branches locales en lignes compactes, branches seulement distantes repliées. Chaque liste a sa bascule « tout voir ».
  13. Historique : commits en lignes compactes, tags, commits pas encore poussés.

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…).


Le bandeau

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
  • Ligne 1 : branche, ↑/↓ ou « ✓ à jour » ou « ◌ non publiée », conflits, modifiés et nouveaux.
  • À partir de 72 colonnes : total +a −r (séparé des fichiers par « · ») et PR.
  • À partir de 90 colonnes : âge du fetch (« fetch jamais » quand aucun n'est connu), et nom du dépôt s'il diffère de celui de la session.
  • Un fetch en échec s'affiche dès 64 colonnes.
  • Ligne 2 (à partir de 56 colonnes, seulement s'il y a quelque chose) : opération en cours en rouge, sinon commande /project en file (« ▶ ») ou en cours (spinner), sinon bascule en cours.
  • Pendant le suivi de la CI, son état court remplace les checks de la PR (◌ CI, ◌ CI 1/3, ✓ CI, ✗ CI <check>) ; sans PR, il prend sa place, à partir de 72 colonnes.
  • « ▸ /git » ouvre le panneau. « x » masque le bandeau pour la session ; la ligne d'état sous le prompt prend alors le relais. /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.


Commandes

CommandeEffet
/git-board:gitOuvre le panneau et relit tout. Connue dès le premier message.
/gitLa 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 historyOuvre le panneau sur cet onglet (modifications et historique marchent aussi).
/git hideMasque le bandeau ; la ligne d'état prend le relais.
/git showRé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ées et rafraîchissement

DonnéeEmplacementPorté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.

LectureQuand
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ôt500 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.

Suivi de la CI

  • Quand. Après un 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).
  • Comment. Toutes les 25 s, sur le commit poussé (HEAD en entier) : avec une PR ouverte, 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).
  • Affichage. « ◌ CI en attente » tant que rien n'est lancé, « ◌ CI 1/3 » en cours (finis/total), « ✓ CI réussie », « ✗ CI échouée : <premier check en échec> ». Un check annulé dont un homonyme (même workflow, même nom) ne l'est pas a été remplacé, par exemple par 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.
  • Fin. Un toast et la zone d'activité à la fin (réussie, ou échouée avec le nom du check). Au bout de 20 min, le suivi s'arrête sur « CI encore en cours (suivi arrêté) ». Toujours aucun check au bout de 3 min : « aucun check », sans toast.
  • Arrêt. Au changement de branche ou de dossier. gh absent, non connecté ou illisible : le suivi s'arrête sans rien afficher, le panneau reste comme avant.

Suggestion de fin de tour

À 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.


Architecture

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.


Développement

cd ~
claude plugin validate plugins/git-board
claude plugin test plugins/git-board
  • Lancer les commandes depuis un chemin court (~) : depuis un dossier au chemin trop long, Windows refuse le dossier courant.
  • Les tests simulent git et gh (fakeRepo). Le mock repère la sous-commande après color.ui=never : ne pas changer l'ordre des options de git().
  • Ils montent le panneau sur 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).
  • Dans le kit de test, un hook qui remplace une opération du moteur (process.run, session.cwd…) doit rendre { value }.
  • Les versions précédentes sont dans l'historique git du dépôt ; celles d'avant le passage à git restent dans ~/.claude/mods-archives/git-board-<version> sur le PC Windows.

Limites connues

  • Animations. Les dessins sont des images : sur le desktop, les animations SVG ne tournent probablement pas.
  • Fraîcheur du distant. Sans fetch périodique, « à jour » et « à récupérer » datent du dernier fetch : son âge est affiché (« fetch jamais » quand ni la session ni FETCH_HEAD n'en donnent ; un dépôt tout juste cloné n'a pas encore de FETCH_HEAD).
  • Hooks du dépôt. $.process.run ne lance pas les hooks git : c'est pour ça que commit, pull, push et PR passent par /project:*.
  • CI hors GitHub Actions sans PR. Sans PR, seules les exécutions GitHub Actions du commit sont lues (gh run list) : les checks d'autres services n'apparaissent qu'avec une PR.
  • Ordre des bandeaux. Avec plusieurs mods, l'ordre vertical des bandeaux dépend de l'ordre de chargement des plugins.
Source 5 files
hooks/register.tsx 786 lines
1// 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}
786
hooks/model.ts 461 lines
1// 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}
461
hooks/view.tsx 1148 lines
1// 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}
1148
hooks/svg.ts 326 lines
1// 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 => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' })[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}
326
types/index.d.ts 180 lines
1/** 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