PageSourceSearch

https://creaconnect.world/src/app-retouche-code.js

js creaconnect.world collected 2026-09-24 10:08:06 UTC 83,137 bytes, 1,451 lines download raw bytes

1/* ═══════════════════════════════════════════════════════════════════════════
2 * LES FICHIERS DU STUDIO CODE — LA RETOUCHE, LA FUSION, ET L'HISTOIRE
3 * ═══════════════════════════════════════════════════════════════════════════
4 *
5 * DOUZIÈME MORCEAU DÉTACHÉ DE `src/app.js`.
6 *
7 * LE CALCUL EST PUR, ET C'EST CE QUI SE MESURE. Les fonctions qui décident —
8 * lire une retouche, la poser, fusionner, noter un état, y remonter — ne
9 * touchent ni l'écran ni `STATE`, et ne rendent pas l'heure sans qu'on la leur
10 * donne : on les rejoue dans Node telles qu'elles sont écrites
11 * (`tests/retouche-code-smoke.mjs`).
12 *
13 * DEUX FONCTIONS FONT EXCEPTION, ET ELLES SONT NOMMÉES : `vueDesVersions` met
14 * l'historique en forme pour l'écran, et `window.codeRestaurerVersion` est le
15 * geste de la personne. Elles vivent ici parce que c'est le sujet de ce
16 * fichier — les fichiers du Studio Code — et parce que le tronc ne peut plus
17 * grossir. Elles lisent `escHtml`, `timeAgo`, `projetCodeActif`, `save` et
18 * `render` du tronc, au moment de l'appel : la règle du découpage tient
19 * (déclarer, ne rien exécuter au chargement), et la garde ne les rejoue pas.
20 *
21 * ── 1. UNE GÉNÉRATION PARTIELLE REMPLAÇAIT LE PROJET ENTIER ───────────────
22 *
23 * `envoyerCode` faisait `proj.files = obj.files`. Une réponse qui ne réécrit
24 * qu'un fichier faisait tomber le projet à ce fichier ; l'accord du registre
25 * les repoussait ensuite en fin de liste, dans un ordre qui n'était plus le
26 * sien. Ce n'était pas une perte, c'était un désordre — et il était silencieux.
27 *
28 * ── 2. ET CE QU'ELLE ÉCRIVAIT SE FAISAIT ANNULER AU RENDU SUIVANT ─────────
29 *
30 * PROUVÉ EN EXÉCUTANT L'ACCORD, pas en le relisant. Les objets rendus par le
31 * parseur sont des `{path, lang, content}` : ni `fid`, ni `majAt`.
32 * `synchroniserStudioCode` compare `f.majAt || 0` à `e.majAt` et, à égalité
33 * comme en dessous, LE REGISTRE GAGNE — c'est écrit dans sa note, et c'est
34 * juste pour un renommage. Résultat : régénérer un fichier qui existait déjà
35 * remettait l'ANCIEN contenu, pendant que le fil affichait « ✅ 1 fichier(s)
36 * généré(s) ». La génération était produite, facturée, et annulée.
37 *
38 * D'où la seule règle de `fusionnerFichiersCode` : ce qu'on écrit est DATÉ.
39 * Une frappe dans l'éditeur est datée, une génération doit l'être aussi —
40 * sinon elle n'existe pas.
41 *
42 * ── 3. IL FALLAIT RÉÉCRIRE DEUX MILLE LIGNES POUR EN CHANGER TROIS ────────
43 *
44 * Le studio ne savait rendre que des fichiers ENTIERS. `retouchesDuTexte` lit
45 * la forme que `STUDIOS.code.defautModif` demande, et `appliquerRetouches` la
46 * pose. Les deux bouts sont tenus ensemble par la garde : un contrat que le
47 * parseur ne relit pas est un gabarit imposé pour rien.
48 *
49 * ── CE QUE CES TROIS FONCTIONS NE TIENNENT PAS ───────────────────────────
50 *
51 * Que le modèle recopie le texte AVANT sans faute. Ça ne se prouve pas hors
52 * ligne. Ce qui se prouve, et qui est tenu ici : une retouche qui ne retrouve
53 * pas son texte n'est PAS appliquée, elle est NOMMÉE, et le fichier ressort
54 * intact. Une retouche à moitié posée serait pire que pas de retouche.
55 *
56 * ── LA RÈGLE DU DÉCOUPAGE ────────────────────────────────────────────────
57 *
58 * Comme les onze autres morceaux : ce fichier se charge APRÈS le tronc et ne
59 * fait que DÉCLARER. Rien ne s'exécute à son chargement, et le tronc ne
60 * l'appelle pas pendant le sien. Tenu par `tests/scripts-charges-smoke.mjs`.
61 */
62
63/* eslint-disable no-unused-vars */
64
65/* LA FORME, ÉCRITE UNE FOIS. C'est elle que `STUDIOS.code.defautModif` décrit
66   au modèle et que la garde relie aux deux bouts. Les bornes sont celles d'un
67   conflit de fusion — sept chevrons, sept signes égal — parce que tout modèle
68   les a déjà vues mille fois et qu'aucun code source n'en porte par accident.
69
70   `[^\s`]+` pour le chemin : ni espace ni accent grave, donc le bloc ne peut
71   pas manger sa propre clôture. Le `?` après \n avant la clôture laisse passer
72   un fichier qui se termine sans retour à la ligne. */
73const RETOUCHE_BLOC = /```modif:([^\s`]+)\n<<<<<<< AVANT\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> APR[ÈE]S\n?```/g
74
75function retouchesDuTexte(contenu) {
76  const t = String(contenu == null ? '' : contenu)
77  const sorties = []
78  /* `lastIndex` remis à zéro. UNE REGEX /g DE HAUT NIVEAU GARDE SA POSITION
79     d'un appel à l'autre, et c'est le piège classique — mais il n'est PAS
80     atteignable ici, et le dire autrement serait mentir : la boucle `while`
81     va toujours jusqu'au `null` final, qui remet `lastIndex` à zéro tout seul.
82     La ligne tient donc contre le `break` que quelqu'un ajoutera un jour, pas
83     contre un défaut d'aujourd'hui. Éprouvée : je l'ai retirée, et la garde
84     est restée verte — c'est ce qui a fait corriger ce commentaire-ci. */
85  RETOUCHE_BLOC.lastIndex = 0
86  let m
87  while ((m = RETOUCHE_BLOC.exec(t)) !== null) {
88    sorties.push({ chemin: m[1], avant: m[2], apres: m[3] })
89  }
90  return sorties
91}
92
93/* POSER LES RETOUCHES — ET DIRE CE QU'ON N'A PAS POSÉ.
94 *
95 * Elle rend une LISTE NEUVE ; les fichiers qu'elle touche sont des objets
96 * neufs, ceux qu'elle ne touche pas sont les mêmes. On ne mute pas l'entrée :
97 * l'appelant a besoin de l'état d'avant pour la vue Changements.
98 *
99 * TROIS REFUS, ET CHACUN A SA RAISON ÉCRITE :
100 *   · le fichier n'existe pas — on ne le CRÉE pas depuis une retouche : une
101 *     retouche dont le fichier manque est un chemin faux, pas un fichier neuf ;
102 *   · le texte AVANT ne s'y trouve pas — le modèle l'a écrit de mémoire ;
103 *   · il s'y trouve DEUX FOIS — on ne devine pas laquelle des deux, et poser
104 *     la première serait juste une fois sur deux. */
105/* PLUS RÉCENT, PAS « AUSSI RÉCENT ». `synchroniserStudioCode` compare
106   `f.majAt || 0` à `e.majAt` et, À ÉGALITÉ, LE REGISTRE GAGNE — c'est écrit
107   dans sa note, et c'est juste : renommer dans la page Fichiers et accorder
108   dans la foulée tient dans la même milliseconde.
109   Donc `Date.now()` ne suffit pas. Une génération qui retombe sur la même
110   milliseconde que le dernier accord serait ANNULÉE — et c'est arrivé, en
111   écrivant la garde : elle a échoué sur ce cas-là avant d'échouer sur rien.
112   On date d'un cran au-dessus de ce que le fichier portait : ce qu'on vient
113   d'écrire est plus récent que ce qu'il y avait, par construction. */
114function _plusRecentQue(fichier, quand) {
115  return Math.max(quand, ((fichier && fichier.majAt) || 0) + 1)
116}
117
118function appliquerRetouches(fichiers, retouches, maintenant) {
119  const liste = Array.isArray(fichiers) ? fichiers : []
120  const quand = maintenant || Date.now()
121  const parChemin = Object.create(null)
122  liste.forEach(function (f, i) { parChemin[String(f.path || f.name || '')] = i })
123
124  const sortie = liste.slice()
125  const posees = []
126  const refusees = []
127
128  ;
128(retouches || []).forEach(function (r) {
129    const chemin = String(r.chemin || '')
130    const i = parChemin[chemin]
131    if (i === undefined) {
132      refusees.push({ chemin: chemin, pourquoi: "ce fichier n'existe pas dans le projet" })
133      return
134    }
135    const avant = String(sortie[i].content == null ? '' : sortie[i].content)
136    const cherche = String(r.avant == null ? '' : r.avant)
137    if (!cherche) { refusees.push({ chemin: chemin, pourquoi: 'le texte AVANT est vide' }); return }
138    const premier = avant.indexOf(cherche)
139    if (premier < 0) {
140      refusees.push({ chemin: chemin, pourquoi: 'le texte AVANT ne se trouve pas dans le fichier' })
141      return
142    }
143    if (avant.indexOf(cherche, premier + 1) >= 0) {
144      refusees.push({ chemin: chemin, pourquoi: 'le texte AVANT s\'y trouve plusieurs fois — il faut en prendre plus pour le rendre unique' })
145      return
146    }
147    /* LA DATE, ENCORE. Même raison que dans la fusion : sans elle, l'accord du
148       registre rend l'ancien contenu au rendu suivant et la retouche disparaît
149       sans un mot. `fid` est conservé — c'est par lui que l'accord retrouve
150       l'entrée au lieu d'en fabriquer une seconde. */
151    sortie[i] = Object.assign({}, sortie[i], {
152      content: avant.slice(0, premier) + String(r.apres == null ? '' : r.apres) + avant.slice(premier + cherche.length),
153      majAt: _plusRecentQue(sortie[i], quand),
154    })
155    posees.push(chemin)
156  })
157
158  return { fichiers: sortie, posees: posees, refusees: refusees }
159}
160
161/* FUSIONNER — un fichier rendu remplace le sien, les autres restent en place.
162 *
163 * L'ORDRE EST CELUI DU PROJET, pas celui de la réponse. Un projet dont les
164 * fichiers changent de place à chaque génération est un projet qu'on ne
165 * retrouve plus ; les fichiers VRAIMENT neufs vont à la fin, dans l'ordre où
166 * le modèle les a écrits.
167 *
168 * `fid` SURVIT au remplacement : sans lui, l'accord du registre ne reconnaît
169 * plus l'entrée par son identifiant et retombe sur une recherche par nom —
170 * qui marche, jusqu'au jour où deux fichiers portent le même.
171 *
172 * ══ ET QUAND LES DEUX ONT TRAVAILLÉ SUR LE MÊME FICHIER ═══════════════════
173 *
174 * « Le mode manuel, ça veut dire la personne travaille. »
175 *
176 * Le Studio Code n'a pas de sélecteur IA/Manuel, et il n'en a pas besoin : le
177 * GESTE sépare déjà les deux — taper dans l'éditeur dit « manuel », envoyer un
178 * message dit « IA ». Ce qui manquait n'était pas une bascule, c'était
179 * l'ARBITRAGE quand les deux gestes portent sur le même fichier.
180 *
181 * ── CE QUI SE PASSAIT, ET C'ÉTAIT SILENCIEUX ─────────────────────────────
182 *
183 * Cette ligne : `Object.assign({}, sortie[i], n, …)`. `n` est la version de
184 * Créa ; elle écrase `content`, et `_plusRecentQue` lui donne une date
185 * STRICTEMENT plus récente — donc elle regagne aussi l'accord suivant avec le
186 * registre. Une génération dure quelques secondes ; ce qu'on tape pendant ce
187 * temps-là dans le fichier qu'elle réécrit disparaît à l'atterrissage. Pas de
188 * conflit affiché, pas de refus, pas une ligne dans le fil.
189 *
190 * ── LA RÈGLE ─────────────────────────────────────────────────────────────
191 *
192 * `luAt` est le moment où Créa a LU le projet — l'instant où son état est parti
193 * dans le prompt. Un fichier dont la date dépasse ce moment a été touché par la
194 * personne APRÈS que Créa l'a lu : ce que Créa rend est alors une PROPOSITION
195 * (`f.propose`), posée à côté du fichier, jamais à sa place. Elle se lit dans la
196 * vue Changements, ligne à ligne, et la personne accepte ou refuse.
197 *
198 * ── ET UNE RETOUCHE, ELLE, S'APPLIQUE QUAND MÊME ─────────────────────────
199 *
200 * `appliquerRetouches` n'est PAS arbitrée, et c'est une différence de nature,
201 * pas un oubli. Une retouche est ancrée sur son texte AVANT : si la personne a
202 * changé cette région-là, elle est déjà refusée par le contrôle qui existe
203 * (« le texte AVANT ne se trouve pas ») ; si elle a changé ailleurs, la
204 * retouche se pose proprement sur SA version. Un correctif ciblé n'écrase rien.
205 * C'est le fichier réécrit en entier qui écrase.
206 *
207 * ── SANS `luAt`, RIEN NE CHANGE ──────────────────────────────────────────
208 *
209 * `luAt` vaut zéro tant que personne ne l'a posé, et l'arbitrage se tait alors.
210 * Une lecture jamais datée ferait sinon de CHAQUE fichier une proposition — le
211 * studio n'écrirait plus rien, et ce serait pire que le défaut d'origine. */
212function fusionnerFichiersCode(anciens, neufs, maintenant, luAt) {
213  const base = Array.isArray(anciens) ? anciens : []
214  const quand = maintenant || Date.now()
215  const lu = Number(luAt) > 0 ? Number(luAt) : 0
216  const sortie = base.slice()
217  const parChemin = Object.create(null)
218  sortie.forEach(function (f, i) { parChemin[String(f.path || f.name || '')] = i })
219
220  ;
220(neufs || []).forEach(function (n) {
221    const chemin = String(n.path || n.name || '')
222    if (!chemin) return
223    const i = parChemin[chemin]
224    if (i === undefined) {
225      parChemin[chemin] = sortie.length
226      sortie.push(Object.assign({}, n, { path: chemin, majAt: quand }))
227      return
228    }
229    const toucheDepuis = lu > 0 && Number(sortie[i].majAt || 0) > lu
230    if (toucheDepuis) {
231      /* ON NE DATE PAS LE FICHIER. Poser une proposition n'est pas l'écrire :
232         si `majAt` bougeait ici, la version de Créa gagnerait l'accord du
233         registre sans que personne l'ait acceptée — c'est-à-dire par la porte
234         de derrière, exactement ce que l'arbitrage empêche par la grande. */
235      sortie[i] = Object.assign({}, sortie[i], {
236        propose: { content: String(n.content == null ? '' : n.content), quand: quand },
237      })
238      return
239    }
240    sortie[i] = Object.assign({}, sortie[i], n, { path: chemin, majAt: _plusRecentQue(sortie[i], quand) })
241    /* UNE PROPOSITION EN ATTENTE NE SURVIT PAS À SON PROPRE FICHIER RÉÉCRIT.
242       Sans cette ligne, `Object.assign` la recopie telle quelle : on garderait
243       une proposition qui compare l'ancien contenu à une suggestion périmée, et
244       l'accepter ramènerait un état que plus personne n'a demandé. */
245    if (sortie[i].propose) delete sortie[i].propose
246    if (base[i] && base[i].fid) sortie[i].fid = base[i].fid
247  })
248
249  return sortie
250}
251
252/* ACCEPTER, OU REFUSER. Les deux gestes vivent ici, avec la règle qui a créé la
253   proposition — un geste rangé loin de sa mécanique finit par ne plus la
254   décrire.
255   ACCEPTER, C'EST ÉCRIRE, donc c'est daté : sans date, l'accord du registre
256   rend l'ancien contenu au rendu suivant et l'acceptation disparaît sans un
257   mot. C'est la même raison que partout ailleurs dans ce fichier. */
258function accepterLaProposition(fichier, maintenant) {
259  if (!fichier || !fichier.propose) return false
260  fichier.content = String(fichier.propose.content == null ? '' : fichier.propose.content)
261  fichier.majAt = _plusRecentQue(fichier, maintenant || Date.now())
262  delete fichier.propose
263  return true
264}
265
266function refuserLaProposition(fichier) {
267  if (!fichier || !fichier.propose) return false
268  delete fichier.propose
269  return true
270}
271
272/* CE QUE LE FIL DIT QUAND UNE RETOUCHE EST REFUSÉE.
273 *
274 * Une ligne par refus, avec le chemin et la raison. Elle est ici, et pas dans
275 * le tronc, pour la même raison que le reste : c'est la retouche qui sait ce
276 * qu'elle n'a pas pu faire, et une phrase écrite à côté de la mécanique finit
277 * par ne plus la décrire. */
278function phraseDesRefus(refusees) {
279  if (!refusees || !refusees.length) return ''
280  return '\n\n⚠️ ' + refusees.length + ' retouche(s) non appliquée(s) — le fichier est resté tel quel :\n'
281    + refusees.map(function (r) { return '· ' + r.chemin + ' — ' + r.pourquoi }).join('\n')
282}
283
284/* ── L'ATTERRISSAGE D'UNE RÉPONSE DE CODE, EN UN SEUL ENDROIT ──────────────
285 *
286 * Ce que le tronc faisait à la main, et qu'il ne doit pas tenir : dans quel
287 * ordre poser les retouches et les fichiers, quel fichier ouvrir ensuite, et
288 * quoi dire dans le fil. Trois décisions de métier, et le métier est ici.
289 *
290 * L'ORDRE, ET IL N'EST PAS INTERCHANGEABLE. Une retouche vise le fichier TEL
291 * QU'IL EST. Si un fichier entier rendu dans la même réponse écrasait d'abord
292 * ce fichier-là, le texte AVANT ne s'y trouverait plus et la retouche serait
293 * refusée pour une raison qu'on aurait fabriquée soi-même.
294 *
295 * ON OUVRE SUR CE QU'ON VIENT DE TOUCHER. Le tronc posait `activeCodeFile = 0`
296 * — le premier fichier du PROJET, qui n'est plus celui de la réponse dès lors
297 * qu'on fusionne au lieu de remplacer. */
298/* ═══════════════════════════════════════════════════════════════════════════
299 * CE QUI ATTERRIT EST-IL SEULEMENT LISIBLE ?
300 *
301 * Rien ne le vérifiait. Un fichier arrivait cassé — une accolade en trop, un
302 * `package.json` avec une virgule finale — et il se posait sans un mot. On le
303 * découvrait en cliquant sur Exécuter, devant un cadre blanc, et il fallait
304 * remonter soi-même jusqu'au fichier fautif. Un studio professionnel dit
305 * l'erreur AU MOMENT où elle entre, pas trois gestes plus tard.
306 *
307 * ── ON NE JUGE QUE CE QU'ON PEUT JUGER SANS SE TROMPER ────────────────────
308 *
309 * Une fausse alerte de syntaxe est pire que pas d'alerte : elle envoie
310 * chercher un défaut qui n'existe pas, dans du code qui marche. Donc :
311 *
312 *   · JSON — `JSON.parse`. Exact, et le moteur donne la POSITION, qu'on
313 *     convertit en numéro de ligne. Un `package.json` cassé est le cas le plus
314 *     silencieux du lot : plus rien ne s'installe et rien ne le dit.
315 *   · JS classique — `new Function(code)` COMPILE sans exécuter (vérifié :
316 *     un appel posé dans le corps ne part pas). C'est donc un analyseur
317 *     syntaxique gratuit, déjà dans le navigateur.
318 *   · Le reste — SILENCE. Voir ci-dessous.
319 *
320 * ── LES DEUX FAUX POSITIFS, ET CE QU'ON EN FAIT ───────────────────────────
321 *
322 *   · `await` au premier niveau lève dans une fonction ordinaire alors que le
323 *     code est juste. On rejoue en fonction ASYNC, et on ne conclut que si les
324 *     DEUX échouent.
325 *   · `import` / `export` lèvent toujours — « Cannot use import statement
326 *     outside a module » — sur du code parfaitement valide. Rien ne rattrape
327 *     ça sans un vrai analyseur, donc ces fichiers ne sont PAS jugés. Idem
328 *     pour JSX et TypeScript, qui ne sont pas du JavaScript.
329 *
330 * Le silence est ici la bonne réponse : ce qui est dit est vrai, et ce qui
331 * n'est pas dit n'est pas affirmé faux.
332 * ══════════════════════════════════════════════════════════════════════════ */
333
334/* Au-delà, on ne compile pas : un fichier de cette taille dans le studio est
335   déjà un cas limite, et l'analyse bloquerait le fil au moment du rendu. */
336const SYNTAXE_SIGNES_MAX = 400000
337const _RE_MODULE = /^[ \t]*(import|export)[\s{*]/m
338const _AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
339
340function _ligneDePosition(texte, position) {
341  const n = Number(position)
342  if (!Number.isFinite(n) || n < 0) return null
343  return String(texte).slice(0, n).split('\n').length
344}
345
346/** Rend `[{ chemin, ligne, message }]` — vide quand tout se compile ou que
347    rien n'est jugeable. Pure : aucune écriture, aucun DOM. */
348function verifierLaSyntaxe(fichiers) {
349  const constats = []
350  for (const f of (fichiers || [])) {
351    if (!f) continue
352    const chemin = String(f.path || f.name || '')
353    const code = String(f.content == null ? '' : f.content)
354    if (!code.trim() || code.length > SYNTAXE_SIGNES_MAX) continue
355
356    if (/\.json$/i.test(chemin)) {
357      try { JSON.parse(code) } catch (e) {
358        const m = String((e && e.message) || e)
359        /* LE NUMÉRO DE LIGNE N'EST CALCULÉ QUE SI LE MOTEUR NE LE DONNE PAS.
360           V8 récent écrit déjà « (line 4 column 1) » dans le message ; le
361           répéter donnerait « ligne 4 — … (line 4 column 1) », qui se lit
362           comme deux mesures indépendantes qui tomberaient d'accord. D'autres
363           moteurs ne donnent que la position : c'est pour eux qu'on convertit. */
364        const pos = m.match(/position (\d+)/)
365        const deja = /\bline \d+/.test(m)
366        constats.push({ chemin, ligne: !deja && pos ? _ligneDePosition(code, pos[1]) : null, message: m })
367      }
368      continue
369    }
370    if (!/\.(js|mjs|cjs)$/i.test(chemin)) continue
371    if (_RE_MODULE.test(code)) continue
372    try { new Function(code) } catch (e1) {
373      try { new _AsyncFunction(code) } catch (e2) {
374        constats.push({ chemin, ligne: null, message: String((e2 && e2.message) || e2) })
375      }
376    }
377  }
378  return constats
379}
380
381/** Ce que le fil en dit. Vide quand il n'y a rien à dire. */
382function phraseDesSyntaxes(constats) {
383  if (!constats || !constats.length) return ''
384  return '\n⚠ ' + constats.length + ' fichier(s) ne se compile(nt) pas :\n'
385    + constats.map(function (c) {
386      return '· ' + c.chemin + (c.ligne ? ' ligne ' + c.ligne : '') + ' — ' + c.message
387    }).join('\n')
388}
389
390/* ══════════════════════════════════════════════════════════════════════════
391 * LA MÊME MACHINE, POUR UN ATELIER QUI N'EST PAS UN PROJET
392 * ══════════════════════════════════════════════════════════════════════════
393 *
394 * Le Studio Web tient UNE page, pas un arbre de fichiers. Il avait donc, seul
395 * contrat de sortie, « rends un document complet » — et « change la couleur du
396 * bouton » faisait réémettre vingt-trois mille signes, dont le modèle
397 * réinventait au passage tout ce qu'il n'avait pas relu.
398 *
399 * On ne lui écrit PAS un second poseur. Un atelier d'un seul document est un
400 * projet d'un seul fichier : on le présente tel quel à `appliquerRetouches`,
401 * qui garde ses trois refus et sa règle d'unicité. Deux poseurs finiraient par
402 * ne plus refuser les mêmes choses, et personne ne s'en apercevrait avant
403 * qu'une page soit à moitié posée.
404 *
405 * Le chemin est fixe — c'est celui que `STUDIOS.web.defautModif` écrit au
406 * modèle, et une retouche qui en vise un autre est REFUSÉE avec son nom,
407 * plutôt que posée au hasard sur le seul document présent.
408 * ══════════════════════════════════════════════════════════════════════════ */
409const ATELIER_UN_SEUL_DOCUMENT = 'page.html'
410
411/* ══ UN ATELIER N'EST PAS TOUJOURS UNE PAGE ═══════════════════════════════
412 *
413 * MESURÉ : `defautModif` n'existait que sur DEUX studios sur quinze — Code et
414 * Web. Partout ailleurs, « change juste ce paragraphe » faisait réémettre le
415 * document entier : c'est exactement le défaut que ce contrat existe pour
416 * fermer, laissé ouvert sur treize lieux.
417 *
418 * ET ON NE PEUT PAS SE CONTENTER DE RECOPIER LE CONTRAT. Un contrat que rien
419 * ne relit est un mensonge de plus — la doctrine de ce fichier. Le poseur
420 * était bordé à `surface !== 'web'` : il fallait d'abord lui apprendre les
421 * autres ateliers.
422 *
423 * TROIS FAMILLES, ET LA DIFFÉRENCE N'EST PAS COSMÉTIQUE — c'est OÙ VIT LA
424 * VÉRITÉ du document :
425 *
426 *   · `html` — Web, Design. Le document EST son HTML. On retouche `html`.
427 *   · `md`   — Document, Business, Coworker. La vérité est le MARKDOWN ;
428 *     `html` en est DÉRIVÉ (`renderMarkdown`). Retoucher le html serait
429 *     retoucher une ombre : le rendu suivant le refabrique depuis le markdown
430 *     inchangé, et la retouche disparaît sans un mot.
431 *   · STRUCTURÉE — Slides, Vidéo, Quiz, Graphique, Tableau de bord. La vérité
432 *     est un JSON (`deck`, `quiz`, `chart`…) dont le html est construit. Ces
433 *     ateliers-là N'ONT PAS de retouche de texte, et n'en reçoivent pas le
434 *     contrat : ce serait la même ombre, en pire.
435 *
436 * LE CHEMIN SUIT LA FAMILLE, parce que c'est lui que le contrat écrit au
437 * modèle et que le poseur REFUSE quand il ne correspond pas. `page.html` pour
438 * un markdown ferait refuser toutes les retouches, avec un motif juste et
439 * incompréhensible. */
440const FAMILLES_D_ATELIER = [
441  { champ: 'html', chemin: ATELIER_UN_SEUL_DOCUMENT },
442  { champ: 'md', chemin: 'document.md' },
443]
444/* CE QUI DIT QU'UN DOCUMENT EST STRUCTURÉ : il porte sa donnée. On liste les
445   clés plutôt qu'un `kind`, parce que c'est la donnée que `detectStudioArtifact`
446   pose ET relit — un `kind` peut manquer, la clé non. */
447const DONNEES_STRUCTUREES = ['deck', 'quiz', 'cards', 'chart', 'dashboard', 'storyboard']
448
449/** La famille de l'atelier courant, ou `null` s'il n'y a rien à retoucher.
450 *  `null` couvre trois cas qui se ressemblent et ne sont pas les mêmes :
451 *  pas de document, un document structuré, un document vide. */
452function familleDeLAtelier(doc) {
453  if (!doc) return null
454  for (const k of DONNEES_STRUCTUREES) if (doc[k]) return null
455  /* L'ORDRE COMPTE : un document markdown porte AUSSI un `html` dérivé. Si on
456     testait `html` d'abord, tous les documents du Studio Document tomberaient
457     dans la famille HTML — et chaque retouche serait posée sur l'ombre. */
458  if (typeof doc.md === 'string' && doc.md.trim()) return FAMILLES_D_ATELIER[1]
459  if (typeof doc.html === 'string' && doc.html.trim()) return FAMILLES_D_ATELIER[0]
460  return null
461}
462
463function poserLaRetoucheDeLAtelier(avant, retouches, maintenant, chemin) {
464  const liste = Array.isArray(retouches) ? retouches : []
465  if (!liste.length) return null
466  const pose = appliquerRetouches(
467    [{ path: chemin || ATELIER_UN_SEUL_DOCUMENT, content: String(avant == null ? '' : avant) }],
468    liste,
469    maintenant || Date.now()
470  )
471  return {
472    /* AUCUNE RETOUCHE POSÉE → on rend `null` pour le document : l'appelant
473       garde la page telle quelle. Rendre le contenu inchangé marcherait aussi,
474       mais ferait passer un tour blanc pour une écriture, et l'historique de
475       versions garderait une version identique à la précédente. */
476    html: pose.posees.length ? pose.fichiers[0].content : null,
477    posees: pose.posees.length,
478    dit: '✅ ' + (pose.posees.length ? pose.posees.length + ' retouche(s) posée(s)' : 'rien à écrire'),
479    refus: phraseDesRefus(pose.refusees),
480  }
481}
482
483/* CE QUE LE TRONC APPELLE — trois lignes, pas trente.
484 *
485 * `src/app.js` a un plafond de lignes qui ne peut que descendre, et il a
486 * raison : ce qui s'écrit de neuf vit dans un morceau. Le tronc garde donc
487 * l'appel, et la lecture, la pose et la phrase du fil vivent ici, avec le
488 * poseur qu'elles emploient. */
489
490/** Ce studio a-t-il retouché son document ? Rend l'artefact, ou null.
491 *  On lit AVANT le bloc ```html : le contrat dit « ou tu retouches, ou tu
492 *  refais », et devant les deux formes on prend la retouche, qui est la
493 *  lecture la moins destructrice.
494 *
495 *  CE N'EST PLUS `surface !== 'web'`. La borne n'était pas le LIEU, c'était ce
496 *  que l'atelier CONTIENT : un document dont la vérité est du texte se
497 *  retouche, un document structuré non. `familleDeLAtelier` le dit, et elle le
498 *  dit pour les quinze studios d'un coup — au lieu d'une liste de noms qu'il
499 *  faudrait rallonger à chaque lieu ajouté. */
500function artefactDeRetoucheDeLAtelier(contenu, docAvant) {
501  if (!familleDeLAtelier(docAvant)) return null
502  const retouches = retouchesDuTexte(contenu)
503  return retouches.length ? { kind: 'web', retouches: retouches, html: null, md: contenu } : null
504}
505
506/** Poser cette retouche sur l'atelier qui est PARTI DANS LE PROMPT — c'est de
507 *  ce texte-là que le modèle a recopié son AVANT, d'aucun autre. Écrit
508 *  `artifact.html`, et rend ce qu'il faut dire dans le fil. */
509function atterrirLaRetoucheDeLAtelier(artifact, docAvant, maintenant) {
510  const famille = familleDeLAtelier(docAvant)
511  const avant = famille ? String(docAvant[famille.champ] || '') : ''
512  if (!avant) {
513    artifact.html = ''
514    return { dit: '', posees: 0, refus: "\n\n⚠️ Aucun document n'est ouvert dans l'atelier : il n'y a rien à retoucher.", phrase: "⚠️ Aucun document n'est ouvert dans l'atelier : il n'y a rien à retoucher." }
515  }
516  const pose = poserLaRetoucheDeLAtelier(avant, artifact.retouches, maintenant, famille.chemin)
517  /* AUCUNE RETOUCHE POSÉE → le document d'avant reste. Le remplacer par du vide
518     effacerait le travail au moment précis où l'on vient de dire pourquoi on
519     n'y a pas touché. */
520  const apres = (pose && pose.html) || avant
521  if (famille.champ === 'md') {
522    /* LE MARKDOWN EST LA VÉRITÉ, LE HTML EN EST TIRÉ — et il faut le retirer
523       ici, sinon le document afficherait l'ancien texte en gardant le nouveau
524       en mémoire : le pire des deux états, parce qu'il a l'air de marcher.
525       `renderMarkdown` vit dans le tronc, qui charge AVANT ce morceau ; on le
526       demande par `typeof` plutôt qu'à nu — le cliquet des portées compte les
527       emprunts implicites, et il ne peut que descendre. Sans lui, on garde le
528       markdown à jour et le html d'avant : dégradé, jamais faux. */
529    artifact.md = apres
530    if (typeof renderMarkdown === 'function') { try { artifact.html = renderMarkdown(apres) } catch (e) { artifact.html = docAvant.html || '' } }
531    else artifact.html = docAvant.html || ''
532  } else {
533    artifact.html = apres
534  }
535  /* LA PHRASE REVIENT AVEC LA POSE, et ce n'est pas de la commodité : le tronc
536     n'emprunte alors qu'UN nom à ce fichier au lieu de deux. Un emprunt
537     implicite entre morceaux tient tant que l'ordre des <script> tient — le
538     cliquet des portées les compte, et il ne peut que descendre. */
539  pose.phrase = phraseDuTourDAtelier(pose, artifact)
540  return pose
541}
542
543/** Ce que le fil dit d'un tour. Une retouche n'est pas une création : « ouvert
544 *  dans le navigateur » devant deux lignes changées fait chercher un nouveau
545 *  document, et surtout ferait passer un REFUS inaperçu. */
546function phraseDuTourDAtelier(pose, artifact) {
547  if (!pose) return '✅ **' + ((artifact && (artifact.file || artifact.title)) || 'Création') + '** — ouvert dans le navigateur (et rangé dans la Médiathèque).'
548  return pose.dit + (pose.posees ? ' — la page est à jour dans le navigateur.' : '') + pose.refus
549}
550
551/* CE QUE LE MODÈLE A ÉCRIT NE SE JETTE PAS.
552 *
553 * « Des fois il ne fait pas de récapitulatif de la tâche, rien, mais il vient
554 *  m'afficher un fichier écrit, diff affiché. C'est quoi ça ? »
555 *
556 * Le tronc posait `content = atterri.dit` : le texte du modèle — son
557 * explication, ce qu'il a changé, ce qu'il reste à faire — était REMPLACÉ par
558 * une phrase mécanique. Le comble : quand la réponse ne contient AUCUN fichier,
559 * le même code garde ce texte EN ENTIER. Produire du travail faisait donc
560 * perdre le compte rendu de ce travail.
561 *
562 * `texte` est la réponse brute. On en retire les blocs de fichiers — ils sont
563 * déjà dans l'atelier, les relire dans le fil serait les lire deux fois — et ce
564 * qui reste EST le récit. La ligne de livraison vient après, en pied, comme une
565 * mesure : elle ne remplace plus rien.
566 */
567function recitDeLaReponse(texte) {
568  return String(texte || '')
569    /* Les blocs NOMMÉS et les retouches sont le LIVRABLE : ils vivent dans
570       l'atelier, pas dans la conversation. Les mêmes motifs que
571       detectStudioArtifact — si l'un change, l'autre doit suivre, et la garde
572       le tient. */
573    .replace(/```\w+:[^\s]+\n[\s\S]*?```/g, '')
574    .replace(/```modif\n[\s\S]*?```/g, '')
575    /* Un bloc anonyme assez long est traité comme un fichier par
576       detectStudioArtifact (le seuil de cent caractères vient de là) : il part
577       avec les autres, pour la même raison. */
578    .replace(/```\w*\n[\s\S]{100,}?```/g, '')
579    .replace(/\n{3,}/g, '\n\n')
580    .trim()
581}
582
583/* Un geste par fichier touché, dans la forme d'une étape d'outil : le nom, le
584   chemin, et le contenu — c'est lui qui porte le compte des lignes, lu par
585   `diffDeLetape` dans le tronc. Les fichiers PROPOSÉS n'en sont pas : rien n'a
586   été écrit tant que la personne n'a pas accepté. */
587function gestesDeLaPose(avant, apres, posees, neufs) {
588  var touches = {}
589  ;
589(posees || []).forEach(function (c) { if (c) touches[c] = true })
590  ;(neufs || []).forEach(function (f) { var c = f && (f.path || f.name); if (c) touches[c] = true })
591  var ancien = {}
592  ;(avant || []).forEach(function (f) { var c = f && (f.path || f.name); if (c) ancien[c] = String(f.content == null ? '' : f.content) })
593  var gestes = []
594  ;(apres || []).forEach(function (f) {
595    var chemin = f && (f.path || f.name)
596    if (!chemin || !touches[chemin] || f.propose) return
597    var contenu = String(f.content == null ? '' : f.content)
598    var lignesAvant = ancien[chemin] ? ancien[chemin].split('\n').length : 0
599    var lignesApres = contenu ? contenu.split('\n').length : 0
600    gestes.push({
601      chemin: chemin,
602      contenu: contenu,
603      /* CE QU'ON SAIT VRAIMENT : un fichier neuf n'enlève rien ; un fichier
604         réécrit gagne ou perd la différence. On ne prétend pas à un diff ligne
605         à ligne — « Changements » le fait, avec les deux versions. */
606      plus: lignesAvant ? Math.max(0, lignesApres - lignesAvant) : lignesApres,
607      moins: lignesAvant ? Math.max(0, lignesAvant - lignesApres) : 0,
608    })
609  })
610  return gestes
611}
612
613/* L'ATELIER FAIT VIVRE SES PASTILLES — ET C'EST LE POSTE DU FLUX QUI LE FAIT,
614   POUR LES DIX STUDIOS À LA FOIS. `gestesEnCours` et `suivreLesGestesDeLAtelier`
615   vivaient ici et ne lisaient qu'UNE forme : ```lang:chemin, celle du Studio
616   Code. Les neuf autres n'avaient donc aucune pastille — le même défaut, neuf
617   fois. `regionsDuTravail` sait découper les TROIS natures depuis toujours :
618   `gestesDuFlux` (src/app-flux-de-studio.js) le lui demande, et ces deux
619   fonctions-ci n'avaient plus qu'à disparaître. Deux méthodes de moins. */
620
621function poserLaReponseDeCode(fichiersDuProjet, art, maintenant, luAt, texte) {
622  const quand = maintenant || Date.now()
623  const neufs = art && Array.isArray(art.files) ? art.files : []
624  const retouches = art && Array.isArray(art.retouches) ? art.retouches : []
625
626  const pose = appliquerRetouches(fichiersDuProjet || [], retouches, quand)
627  const fichiers = fusionnerFichiersCode(pose.fichiers, neufs, quand, luAt)
628
629  const touche = pose.posees[0] || (neufs[0] && (neufs[0].path || neufs[0].name)) || ''
630  const ouvrir = fichiers.findIndex(function (f) { return (f.path || f.name) === touche })
631
632  /* LES PROPOSITIONS SE COMPTENT SUR LE RÉSULTAT, PAS SUR UN SECOND RETOUR.
633     `fusionnerFichiersCode` a trois appelants ; leur faire tous lire une forme
634     de retour nouvelle pour une information déjà lisible dans les fichiers
635     eux-mêmes serait un canal de plus pour rien. */
636  const proposees = fichiers.filter(function (f) { return f && f.propose })
637
638  const dit = []
639  const ecrits = neufs.length - proposees.length
640  if (ecrits > 0) dit.push(ecrits + ' fichier(s) écrit(s)')
641  if (pose.posees.length) dit.push(pose.posees.length + ' retouche(s) posée(s)')
642
643  return {
644    fichiers: fichiers,
645    ouvrir: ouvrir >= 0 ? ouvrir : 0,
646    /* ── LES GESTES, POUR QUE CETTE ROUTE PASSE PAR LES PASTILLES ──────────
647     * « Il y a toujours deux systèmes. Le travail normal passe à travers les
648     *  pastilles. Mais quand il dit "je construis dans l'atelier, regarde à
649     *  droite", il n'utilise pas les pastilles ni rien. »
650     *
651     * C'était vrai, et mesuré : cette route posait des fichiers et le fil les
652     * montrait en CARTES — un affichage à part, rendu seulement une fois le
653     * tour fini. Deux systèmes pour un seul travail.
654     *
655     * Elle rend donc ses actes sous la forme que la frise sait déjà lire : un
656     * geste par fichier, avec ce qu'il a changé. Le tronc n'a qu'à les ouvrir
657     * comme il ouvre ceux d'un outil — même pastille, même diff, même place. */
658    gestes: gestesDeLaPose(fichiersDuProjet || [], fichiers, pose.posees, neufs),
659    dit: '✅ ' + (dit.join(' · ') || 'rien à écrire'),
660    /* LE RÉCIT DU MODÈLE, ET IL PASSE DEVANT LA LIVRAISON. Ce qu'il a compris
661       et fait se lit d'abord ; la ligne de livraison est le PIED de cette
662       phrase-là, pas son remplacement. Vide quand le modèle n'a rien raconté —
663       et là, la livraison reste seule, exactement comme avant. */
664    recit: recitDeLaReponse(texte),
665    /* LA SYNTAXE SE DIT AVEC LES REFUS, dans la même phrase du fil : ce sont
666       deux façons pour un tour de n'avoir pas abouti, et les séparer ferait
667       lire l'une sans l'autre. */
668    refus: phraseDesRefus(pose.refusees) + phraseDesPropositions(proposees)
669      + phraseDesSyntaxes(verifierLaSyntaxe(fichiers)),
670  }
671}
672
673/* CE QUE LE FIL DIT QUAND CRÉA N'A PAS ÉCRIT PARCE QUE VOUS ÉCRIVIEZ.
674 *
675 * Sans cette phrase, l'arbitrage serait aussi silencieux que l'écrasement qu'il
676 * remplace : le fichier ne changerait pas, Créa dirait « fichier écrit », et
677 * personne ne saurait qu'une version attend dans la vue Changements. Un refus
678 * muet et un écrasement muet se ressemblent trait pour trait à l'écran. */
679function phraseDesPropositions(proposees) {
680  if (!proposees || !proposees.length) return ''
681  return '\n\n✋ ' + proposees.length + ' fichier(s) que vous avez modifié(s) pendant que je travaillais — '
682    + 'je ne les ai pas écrasés. Ma version vous attend dans « Changements », à accepter ou à refuser :\n'
683    + proposees.map(function (f) { return '· ' + (f.path || f.name || 'sans nom') }).join('\n')
684}
685
686/* ═══════════════════════════════════════════════════════════════════════════
687 * L'HISTOIRE DU PROJET — CE QU'IL N'Y AVAIT PAS
688 * ═══════════════════════════════════════════════════════════════════════════
689 *
690 * La vue « Changements » le disait elle-même, en toutes lettres à l'écran :
691 * « CRÉA ne garde pas plus loin : il n'y a pas d'historique de versions. »
692 * `_prevFiles` retenait UN cran — l'état d'avant la dernière génération. Deux
693 * générations de suite, et ce qui marchait il y a dix minutes n'existait plus
694 * nulle part. Pas de retour en arrière, pas de branche, pas de « reviens à
695 * avant ».
696 *
697 * ── CE QU'ON GARDE, ET CE QU'ON NE GARDE PAS ─────────────────────────────
698 *
699 * Ce n'est PAS un dépôt Git, et le prétendre serait pire que de ne rien avoir.
700 * Pas de branches, pas de fusion, pas de partage : une pile d'états datés, dans
701 * le projet, qu'on peut remonter. Ce qui tient dans le navigateur de la
702 * personne et rien de plus.
703 *
704 * D'OÙ DEUX BORNES, ET ELLES SONT LÀ POUR LE STOCKAGE. Le projet vit dans
705 * `STATE`, qui est sauvegardé en entier : un historique sans borne remplirait
706 * le quota du navigateur, et la sauvegarde échouerait — en emportant tout le
707 * reste, pas seulement l'historique. On borne donc au NOMBRE et aux OCTETS, et
708 * la plus ancienne cède la place. Le nombre seul ne suffit pas : vingt versions
709 * d'un projet de trois mégaoctets, c'est soixante mégaoctets.
710 *
711 * ── ET LA DATE, ENCORE ───────────────────────────────────────────────────
712 *
713 * Restaurer, c'est écrire. `synchroniserStudioCode` donne la victoire au
714 * registre à égalité de date : une restauration non datée serait ANNULÉE au
715 * rendu suivant, exactement comme l'était une génération. C'est le même
716 * `_plusRecentQue` qui la tient, et c'est la raison pour laquelle l'histoire
717 * vit ici et pas dans un treizième morceau.
718 */
719
720/* Combien d'états on remonte, et combien de place on y met. Vingt pas en
721   arrière couvrent une séance de travail ; quatre mégaoctets, c'est un projet
722   de code confortable répété plusieurs fois, et loin du quota d'un navigateur. */
723const VERSIONS_MAX = 20
724const VERSIONS_OCTETS_MAX = 4 * 1024 * 1024
725
726function _octetsDesFichiers(fichiers) {
727  let n = 0
728  ;(fichiers || []).forEach(function (f) { n += String(f.content || '').length + String(f.path || f.name || '').length })
729  return n
730}
731
732/* NOTER UN ÉTAT — l'état COURANT, avant qu'on y touche.
733 *
734 * Elle rend la NOUVELLE pile ; elle ne mute pas celle qu'on lui donne. Une
735 * version ne garde que ce qui la définit — chemin et contenu — jamais le `fid`
736 * du registre : une entrée supprimée depuis laisserait un identifiant mort qui
737 * ferait recoller la restauration à un fichier qui n'existe plus.
738 *
739 * ON NE NOTE PAS DEUX FOIS LE MÊME ÉTAT. Deux messages de suite sans le moindre
740 * changement rempliraient la pile de copies identiques et pousseraient dehors
741 * les états qui, eux, disaient quelque chose. */
742function noterVersion(versions, fichiers, dit, quand) {
743  const pile = Array.isArray(versions) ? versions.slice() : []
744  const etat = (fichiers || []).map(function (f) {
745    return { path: String(f.path || f.name || ''), content: String(f.content == null ? '' : f.content) }
746  })
747  if (!etat.length) return pile
748
749  const empreinte = etat.map(function (f) { return f.path + '\u0000' + f.content }).join('\u0001')
750  if (pile.length && pile[0]._empreinte === empreinte) return pile
751
752  pile.unshift({
753    id: 'v' + (quand || Date.now()) + '-' + pile.length,
754    at: quand || Date.now(),
755    dit: String(dit || '').slice(0, 120),
756    fichiers: etat,
757    _empreinte: empreinte,
758  })
759
760  while (pile.length > VERSIONS_MAX) pile.pop()
761  /* LA BORNE D'OCTETS SE COMPTE APRÈS COUP, et on garde TOUJOURS la plus
762     récente : un projet plus gros que la borne à lui seul viderait la pile
763     entière et ne garderait rien — un historique qui s'efface à mesure qu'on
764     s'en sert le plus. */
765  let total = 0
766  const gardees = []
767  for (const v of pile) {
768    total += _octetsDesFichiers(v.fichiers)
769    if (gardees.length && total > VERSIONS_OCTETS_MAX) break
770    gardees.push(v)
771  }
772  return gardees
773}
774
775/* CE QUE L'ÉCRAN MONTRE — sans les contenus, qui pèsent et qu'on n'affiche pas.
776   Elle existe pour que la vue n'ait pas à connaître la forme d'une version, et
777   pour que le morceau reste PUR : il rend des données, jamais du HTML. */
778function versionsLisibles(versions) {
779  return (Array.isArray(versions) ? versions : []).map(function (v, i) {
780    return {
781      id: v.id, at: v.at, dit: v.dit,
782      rang: i, fichiers: (v.fichiers || []).length,
783      octets: _octetsDesFichiers(v.fichiers),
784    }
785  })
786}
787
788/* REMONTER À UN ÉTAT.
789 *
790 * ON REMPLACE, ON NE FUSIONNE PAS — et c'est la différence avec
791 * `fusionnerFichiersCode`. Revenir en arrière veut dire que les fichiers créés
792 * DEPUIS s'en vont : les garder rendrait un état qui n'a jamais existé, et
793 * c'est précisément ce qu'on vient chercher ici.
794 *
795 * LE `fid` SUIT LE CHEMIN. Un fichier qui existe encore garde son entrée de
796 * registre ; sans ça, l'accord en fabriquerait une seconde à côté de la
797 * première et la page Fichiers montrerait le fichier en double. */
798function restaurerVersion(fichiersActuels, version, quand) {
799  const actuels = Array.isArray(fichiersActuels) ? fichiersActuels : []
800  const parChemin = Object.create(null)
801  actuels.forEach(function (f) { parChemin[String(f.path || f.name || '')] = f })
802  const t = quand || Date.now()
803  return ((version && version.fichiers) || []).map(function (f) {
804    const chemin = String(f.path || '')
805    const ancien = parChemin[chemin]
806    const neuf = { path: chemin, content: String(f.content == null ? '' : f.content), majAt: _plusRecentQue(ancien, t) }
807    if (ancien && ancien.fid) neuf.fid = ancien.fid
808    return neuf
809  })
810}
811
812/* REMONTER LE TEMPS. La vue disait « il n'y a pas d'historique de versions » —
813   c'était vrai, et c'est ce qui change. Elle ne montre RIEN quand il n'y a rien :
814   une section vide sous chaque projet neuf apprendrait qu'il n'y a jamais rien
815   à y voir. La forme des versions est lue par `versionsLisibles`, qui rend des
816   données et jamais du HTML : ce morceau-là reste pur. */
817function _vueVersions(modeId) {
818  if (modeId !== 'code') return ''
819  const p = typeof projetCodeActif === 'function' ? projetCodeActif() : null
820  const liste = typeof versionsLisibles === 'function' ? versionsLisibles(p && p.versions) : []
821  if (!liste.length) return ''
822  const ligne = (v) => '<div class="flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-[max(0.75rem,12px)]" style="background:var(--bg-secondary)">'
823    + '<span class="flex-1 truncate" style="color:var(--t-primary)" title="' + escHtml(v.dit || '') + '">' + escHtml(v.dit || 'sans description') + '</span>'
824    + '<span class="shrink-0 text-[max(0.625rem,10px)] tabular-nums" style="color:var(--t-faint)">' + v.fichiers + ' fic. \u00b7 ' + timeAgo(v.at) + '</span>'
825    + '<button onclick="window.codeFaireTournerVersion(' + jsAttr(v.id) + ')" class="shrink-0 rounded-lg px-2 py-1 text-[max(0.625rem,10px)] font-medium" style="background:var(--bg-tertiary);border:1px solid var(--border);color:var(--t-secondary)" title="Ouvrir cet \u00e9tat \u00e0 sa propre adresse, sans toucher au projet">Faire tourner</button>'
826    + '<button onclick="window.codeRestaurerVersion(' + jsAttr(v.id) + ')" class="shrink-0 rounded-lg px-2 py-1 text-[max(0.625rem,10px)] font-medium" style="background:var(--accent-bg);color:var(--accent);border:1px solid var(--accent)" title="Remonter le projet \u00e0 cet \u00e9tat">Remonter</button>'
827    + '</div>'
828  return '<div class="mt-4 pt-3" style="border-top:1px solid var(--border)">'
829    + '<p class="text-[max(0.6875rem,11px)] font-semibold px-1 pb-1.5" style="color:var(--t-faint)">Remonter le temps \u2014 ' + liste.length + ' \u00e9tat(s) gard\u00e9(s) \u00b7 « Faire tourner » ouvre un \u00e9tat \u00e0 c\u00f4t\u00e9, sans toucher au projet</p>'
830    + '<div class="space-y-1">' + liste.map(ligne).join('') + '</div></div>'
831}
832
833/* REMONTER À UN ÉTAT. On note l'état COURANT avant de le remplacer : revenir
834   en arrière doit pouvoir se défaire, sinon c'est un aller simple et personne
835   n'ose s'en servir. Le calcul vit dans src/app-retouche-code.js — c'est lui
836   qui DATE ce qu'il écrit, sans quoi l'accord du registre annulerait la
837   restauration au rendu suivant. */
838/* DERRIÈRE UN `typeof window`, comme les autres morceaux. Sans lui, rejouer ce
839   fichier hors navigateur — ce que fait sa propre garde — tombe sur « window is
840   not defined » à la ligne d'exposition, et plus rien n'est mesuré. */
841/* PAS D'EXPOSITION SUR `window` POUR CES DEUX-LÀ. Elles sont déjà joignables
842   par leur nom — les morceaux sont des scripts CLASSIQUES et leurs fonctions de
843   haut niveau vivent dans l'environnement lexical global. Les reposer sur
844   `window` ferait deux noms pour une seule fonction, et c'est précisément le
845   défaut que ce studio a payé six fois. Le GESTE, lui — celui qu'un `onclick`
846   appelle — vit avec l'écran qui le porte : `window.trancherLaProposition`,
847   dans src/app-lecteur-changements.js. */
848
849/* ── FAIRE TOURNER UN ÉTAT PASSÉ, SANS TOUCHER AU PRÉSENT ─────────────────
850 *
851 * Le voisin de ce bouton, « Remonter », est DESTRUCTIF et le dit : « les
852 * fichiers créés depuis seront retirés ». Comparer deux états revenait donc à
853 * choisir entre lire un diff de TEXTE et détruire le présent pour voir le
854 * passé. Or comparer du texte est tout ce que les plateformes de code savent
855 * faire — et ici un état SE PUBLIE : il a une adresse, il tourne, et deux
856 * adresses tiennent côte à côte dans le navigateur du studio, qui a des
857 * onglets.
858 *
859 * CE GESTE NE TOUCHE À RIEN. Pas de `saveActiveCode`, pas de `p.files`, pas de
860 * `STATE.codePreview` — ce dernier EST le présent, et l'écraser pour montrer le
861 * passé serait remonter en croyant regarder. Le document de la version est
862 * construit à part, publié sous SA clé, et l'adresse s'ouvre dans un onglet.
863 * C'est ce qui distingue « faire tourner » de « remonter », et une garde le
864 * tient nommément.
865 *
866 * ET IL NE PUBLIE PAS SOUS LA CLÉ DU PROJET. Celle-là vaut `studio:code:<id>`
867 * et sa forme est gelée pour que les liens déjà partagés tiennent : y écrire un
868 * état passé remplacerait le projet courant à l'adresse que quelqu'un a reçue.
869 * `cleDeLaVersion` (src/crea-vitrine-du-projet.js) garantit la séparation. */
870if (typeof window !== 'undefined') window.codeFaireTournerVersion = async function (id) {
871  const p = typeof projetCodeActif === 'function' ? projetCodeActif() : null
872  if (!p) return
873  const v = (p.versions || []).find(x => x.id === id)
874  /* `window.logNotice`, ET PAS `logNotice` NU — ici et dans les deux refus plus
875     bas. Le tronc la déclare `window.logNotice = function…` : l'appeler par son
876     nom nu en fait un LIEN IMPLICITE entre fichiers, que le cliquet des portées
877     compte et qu'il a refusé pour ce lot. Nommer la provenance coûte huit
878     signes et dit d'où vient la fonction. */
879  if (!v) { window.logNotice('Cet état n\'est plus dans l\'historique.', 'info'); return }
880  const art = typeof projectToArtifact === 'function'
881    ? projectToArtifact({ name: p.name, files: v.fichiers })
882    : null
883  /* UN ÉTAT SANS RIEN D'EXÉCUTABLE LE DIT. Un projet a pu n'avoir que des
884     fichiers de données à ce moment-là ; ouvrir un onglet vide ferait croire
885     que la page est cassée alors qu'il n'y en avait pas. */
886  if (!art) { window.logNotice('Cet état n\'a aucun fichier exécutable (.html, .jsx, .js) : il n\'y a rien à faire tourner.', 'info'); return }
887  const doc = typeof buildArtifactDoc === 'function' ? buildArtifactDoc(art, null) : ''
888  const cle = typeof window.cleDeLaVersion === 'function' ? window.cleDeLaVersion(p, v) : null
889  const nom = typeof window.nomDeLaVersion === 'function' ? window.nomDeLaVersion(p, v) : 'État précédent'
890  /* UN SEUL REFUS POUR LES DEUX ÉCHECS DE PUBLICATION. « la publication n'est
891     pas disponible ici » et « elle a échoué » demandent la même chose à la
892     personne — réessayer ailleurs — et disent la même chose du projet : il
893     n'a pas bougé. Deux lignes d'historique pour un seul geste raté, c'est
894     précisément ce que le cliquet des portes douces compte. */
895  const url = (doc && cle && typeof window.publierAtelier === 'function')
896    ? await window.publierAtelier(doc, nom, cle)
897    : null
898  if (!url) { window.logNotice('Cet état n\'a pas pu être publié — rien n\'a changé dans le projet.', 'info'); return }
899  /* `window.location`, ET PAS `location` NU. Un nom global emprunté sans le
900     dire est un LIEN IMPLICITE entre fichiers — le cliquet des portées les
901     compte, et il a refusé celui-ci. `window` n'appartient à aucun fichier :
902     le passer par lui nomme la provenance au lieu de l'emprunter. */
903  const adresse = window.location.origin + url
904  /* ON OUVRE DANS LE NAVIGATEUR DU STUDIO, ET À CÔTÉ DU PRÉSENT.
905   *
906   * Première version : un ONGLET. Le projet courant tournait dans le premier,
907   * l'état passé dans le second, et on comparait en basculant — de mémoire. La
908   * garde de ce lot-là le disait franchement : « la vue côte à côte n'est pas
909   * tenue, deux onglets sont deux onglets ».
910   *
911   * `CREA_COTE_A_COTE.ouvrir` rend le nom du SECOND espace de noms, et l'état
912   * passé y va : le présent reste à gauche, sous les yeux, pendant qu'on
913   * regarde le passé à droite. Deux pages VIVANTES, pas deux textes.
914   *
915   * ET S'IL REND '' , ON RETOMBE SUR L'ONGLET. C'est le comportement d'avant,
916   * jamais pire — et surtout on ne retombe PAS sur « preview », qui est le
917   * navigateur de l'espace chat : y envoyer un état passé écraserait la page
918   * de quelqu'un qui ne regarde pas ce studio. */
919  const cote = (window.CREA_COTE_A_COTE && window.CREA_COTE_A_COTE.ouvrir('code')) || 'code'
920  STATE.studioFilesPanelOpen = null
921  STATE.studioListeFichiers = null
922  STATE.studioBrowserPanelOpen = 'code'
923  save()
924  /* PAS DE NOTICE DE SUCCÈS : L'ONGLET QUI S'OUVRE EST LE RETOUR. Le cliquet
925     des portes douces du journal l'a refusée, et il a raison — « c'est un
926     retour que le bouton lui-même peut donner sans occuper une ligne
927     d'historique ». Que le projet n'ait pas bougé est dit sur le bouton, dans
928     son `title`. Les deux notices qui restent ici disent un REFUS : celles-là,
929     rien d'autre ne les dirait. */
930  /* `window._rbNavigate`, ET PAS `_rbNavigate` NU — même raison que
931     `window.logNotice` et `window.location` plus haut : un nom global emprunté
932     sans le dire est un lien implicite entre fichiers, et le cliquet des
933     portées les compte. */
934  if (typeof window._rbNavigate === 'function') await window._rbNavigate(cote, adresse, nom)
935  else render()
936}
937
938if (typeof window !== 'undefined') window.codeRestaurerVersion = async function (id) {
939  const p = projetCodeActif(); if (!p) return
940  const v = (p.versions || []).find(x => x.id === id); if (!v) return
941  if (!await confirmModal('Remonter le projet à cet état ? Les fichiers créés depuis seront retirés.', { confirmLabel: 'Remonter' })) return
942  p.versions = noterVersion(p.versions, p.files, 'avant de remonter', Date.now())
943  p._prevFiles = (p.files || []).map(f => ({ path: f.path || f.name, content: f.content || '' }))
944  const apres = restaurerVersion(p.files, v, Date.now())
945  /* CE QUI DISPARAÎT DOIT LE DIRE AU REGISTRE — sinon on n'a rien retiré.
946   *
947   * MESURÉ, en rejouant `noterVersion` et `restaurerVersion` telles qu'elles
948   * sont écrites contre un vrai registre : trois fichiers, sept ajoutés,
949   * « Remonter » → `p.files` retombe bien à 3. Puis l'accord suivant les
950   * REMET TOUS LES DIX, parce que les sept entrées vivent encore et qu'une
951   * entrée sans fichier redevient un fichier. Le geste s'annulait tout seul
952   * au rendu d'après, et le compteur du studio ne descendait jamais.
953   *
954   * `codeProjDeleteFile` et `codeProjetSupprimer` posent la pierre tombale,
955   * chacun avec la même phrase écrite à côté — « le registre est la vérité ».
956   * Celui-ci est le troisième geste qui retire des fichiers, et le seul qui
957   * l'avait oublié. La boîte de dialogue, elle, promettait déjà. */
958  const restent = new Set(apres.map((f) => String(f.path || f.name || '')))
959  for (const f of p.files || []) {
960    if (restent.has(String(f.path || f.name || ''))) continue
961    try { if (typeof window.fichiersOublierFichierStudio === 'function') window.fichiersOublierFichierStudio(f) }
962    catch (e) { /* registre absent : le retrait ne vaut que pour la session, comme les deux autres gestes */ }
963  }
964  p.files = apres
965  /* REMONTER LE TEMPS OUVRE CE QUI A CHANGÉ — et c'est maintenant une VUE,
966     plus un drapeau. Cette ligne a armé successivement `codeDiffMode` puis
967     `codeDiffFichier` : deux états qui transformaient l'éditeur de l'atelier
968     en comparaison côte à côte. Le diff a sa surface. `_prevFiles` vient
969     d'être posé juste au-dessus, donc la vue « Changements » a tout ce qu'il
970     lui faut ; on l'ouvre, au lieu d'armer un état que quelqu'un devra
971     désarmer. */
972  STATE.activeCodeFile = 0
973  if (typeof window !== 'undefined' && typeof window.poserVueStudio === 'function') window.poserVueStudio('code', 'changements')
974  save(); render()
975}
976
977/* UN PROJET DE CODE PAR SON IDENTIFIANT — LA QUESTION APPARTIENT AU STUDIO.
978 *
979 * La page des fichiers doit savoir si un projet existe encore avant d'y sauter :
980 * une entrée du registre survit à la suppression de son projet, et poser un
981 * mort sur la session la laisserait pointée dans le vide. Mais la règle du
982 * registre est qu'une SURFACE ne lit pas les réserves en direct — c'est ce que
983 * tient `tests/fichiers-projet-smoke.mjs`, et elle a raison : deux lectures de
984 * `STATE.codeProjects` divergent au premier renommage. La question se pose donc
985 * ici, au poste qui tient les projets — ce fichier, qui tient déjà l'historique
986 * des versions et le geste qui y remonte. */
987if (typeof window !== 'undefined') window.projetCodeParId = function (id) {
988  if (!id) return null
989  return (STATE.codeProjects || []).find((p) => p && p.id === id) || null
990}
991
992/* ══════════════════════════════════════════════════════════════════════════
993 * LES QUATRE GESTES DU PROJET DE CODE — ILS N'AVAIENT AUCUNE PORTE.
994 *
995 * MESURÉ, ET C'EST LE PLUS GROS TROU DU STUDIO. Trois brancheurs vivaient dans
996 * `bindEvents()` — `[data-code-project]`, `[data-code-proj-rename]`,
997 * `[data-code-proj-del]` — et le dépôt ENTIER ne rendait aucun de ces trois
998 * attributs. Pas « rarement » : zéro fois, en comptant les gabarits, les
999 * morceaux détachés, l'extension et le bureau. On ne pouvait donc ni CHANGER de
1000 * projet, ni le RENOMMER, ni le SUPPRIMER — le code des trois gestes était
1001 * complet et attendait des boutons qui n'ont jamais existé.
1002 *
1003 * Et créer le SECOND projet était impossible aussi : `btn-new-code-project-3`
1004 * n'est dessiné que dans la branche `!active` de l'atelier, c'est-à-dire sur
1005 * l'écran qui dit « votre code apparaîtra dans Fichiers ». Dès qu'un projet
1006 * existe, cet écran disparaît, et avec lui la seule porte.
1007 *
1008 * C'EST EXACTEMENT CE QUE LE DÉPÔT AVAIT DÉJÀ NOMMÉ, deux fois, dans ce même
1009 * fichier : « un brancheur sans bouton ne lève rien, et c'est ce qui le laisse
1010 * en place des années ». Les trois brancheurs partent ; les gestes se posent
1011 * ici, en fonctions nommées, et le menu ⋮ leur ouvre la porte — le seul menu du
1012 * studio dont on est sûr qu'il s'affiche, celui qui porte déjà « Nouveau
1013 * fichier » et « Exécuter dans le navigateur ».
1014 *
1015 * CE QUE LE BRANCHEUR MORT FAISAIT DE FAUX, ET QU'ON NE RECOPIE PAS. Sa
1016 * suppression retirait le projet de `STATE.codeProjects` et s'arrêtait là. Or
1017 * le registre des fichiers est la vérité de ce studio — `codeProjDeleteFile`
1018 * l'écrit en toutes lettres trente lignes plus bas : un fichier retiré sans son
1019 * entrée REVIENT au prochain accord. Un projet supprimé aurait donc laissé tous
1020 * ses fichiers derrière lui. On oublie chaque fichier avant de retirer le
1021 * projet.
1022 * ══════════════════════════════════════════════════════════════════════════ */
1023/* LE PROJET QUE LA DEMANDE FAIT NAÎTRE.
1024 *
1025 * Le Studio Code sortait sans rien faire quand aucun projet n'était ouvert —
1026 * `if (!proj) return`, mesuré dans la vraie page : on écrit, on envoie, rien ne
1027 * part et rien ne le dit. Le Studio Design, lui, crée le sien à la volée.
1028 *
1029 * Il naît ICI, avec les autres projets de code, et pas dans le tronc : c'est ce
1030 * fichier qui sait ce qu'est un projet de code. La demande le NOMME, par la
1031 * règle partagée des titres (src/crea-titre-session.js) — pas par une coupe à
1032 * trente caractères, qui tombe au milieu d'un mot. Ce nom se lit ensuite en
1033 * haut de l'écran, puisque la session porte le titre du projet. */
1034if (typeof window !== 'undefined') window.codeProjetDepuisDemande = function (demande) {
1035  const nom = (typeof titreDeSession === 'function' && titreDeSession(demande)) || 'Projet'
1036  const proj = { id: uid(), name: nom, files: [], createdAt: Date.now() }
1037  STATE.codeProjects = STATE.codeProjects || []
1038  STATE.codeProjects.unshift(proj)
1039  if (typeof poserProjetActif === 'function') poserProjetActif(proj.id, 'code')
1040  return proj
1041}
1042
1043/* ON NE DEMANDE PLUS LE NOM AVANT DE CRÉER. La boîte « Nom du projet… »
1044 * s'ouvrait sur un projet qui n'existait pas encore, et Échap ne créait RIEN :
1045 * le geste le plus fréquent du studio était barré par la question à laquelle
1046 * on est le moins capable de répondre à cet instant. Le nom vient de ce dont on
1047 * parle (crea-nom-qui-vient.js) ; « Renommer », dans le ⋮, redevient une
1048 * correction qu'on fait quand on veut. */
1049if (typeof window !== 'undefined') window.codeNouveauProjet = function () {
1050  STATE.codeProjects = STATE.codeProjects || []
1051  const name = window.CREA_NOM.nomQuiVient('Projet', { existants: window.CREA_NOM.nomsDe(STATE.codeProjects) })
1052  const proj = { id: uid(), name: name, files: [], createdAt: Date.now() }
1053  STATE.codeProjects.unshift(proj)
1054  poserProjetActif(proj.id)
1055  save(); render()
1056}
1057
1058if (typeof window !== 'undefined') window.codeProjetChoisir = function (id) {
1059  if (!id) return
1060  /* SAUVER AVANT DE PARTIR. L'éditeur écrit dans le fichier ouvert du projet
1061     COURANT ; changer de projet sans reposer ce qui est à l'écran perdrait la
1062     frappe des dernières secondes. `saveActiveCode` est le geste que
1063     `codeProjNewFile` appelle déjà pour la même raison. */
1064  try { saveActiveCode() } catch (e) { /* pas d'éditeur monté : il n'y a rien à reposer */ }
1065  poserProjetActif(id)
1066  save(); render()
1067}
1068
1069if (typeof window !== 'undefined') window.codeProjetRenommer = async function (id) {
1070  const p = (STATE.codeProjects || []).find((x) => x.id === id) || projetCodeActif()
1071  if (!p) return
1072  const name = await promptModal('Renommer le projet', '', p.name, 'Renommer')
1073  if (!name || !name.trim()) return
1074  const avant = p.name
1075  p.name = name.trim()
1076  /* ET LES FICHIERS SUIVENT. Le Studio Code range au grain « projet » : le
1077   * dossier d'un fichier vient du NOM de la réalisation. Renommer sans déplacer
1078   * coupait le travail en deux dossiers, dont l'ancien portait un nom qui
1079   * n'existait plus nulle part. Même porte que les douze autres studios. */
1080  try {
1081    if (window.fichiersRegistre && window.fichiersRegistre.suivreLeRenommage) {
1082      window.fichiersRegistre.suivreLeRenommage('code', avant, p.name)
1083    }
1084  } catch (e) { /* registre non monté : le nom change, le rangement se refera plus tard */ }
1085  save(); render()
1086}
1087
1088if (typeof window !== 'undefined') window.codeProjetSupprimer = async function (id) {
1089  const p = (STATE.codeProjects || []).find((x) => x.id === id) || projetCodeActif()
1090  if (!p) return
1091  const n = (p.files || []).length
1092  const ok = await confirmModal(`Supprimer le projet « ${escHtml(p.name)} » et ses ${n} fichier(s) ?`, { confirmLabel: 'Supprimer', danger: true })
1093  if (!ok) return
1094  // Le registre est la vérité : un fichier retiré sans son entrée revient au
1095  // prochain accord — même règle que codeProjDeleteFile, juste en dessous.
1096  for (const f of p.files || []) {
1097    try { if (typeof window.fichiersOublierFichierStudio === 'function') window.fichiersOublierFichierStudio(f) }
1098    catch (e) { /* registre absent : la suppression ne vaut que pour la session */ }
1099  }
1100  // Inscrit avant de retirer : sans marque, l'autre appareil le ressuscite.
1101  if (window.CREA_RECONCILIATION) window.CREA_RECONCILIATION.marquerDans(STATE, 'projetsCodeSupprimes', p.id)
1102  STATE.codeProjects = (STATE.codeProjects || []).filter((x) => x.id !== p.id)
1103  /* LA SESSION QUI VISAIT CE PROJET NE DOIT PAS RESTER POINTÉE SUR UN MORT.
1104     Ce geste y arrivait déjà, par `poserProjetActif` ; le générique des douze
1105     autres studios, non. Les deux demandent maintenant la même porte
1106     (app-domaine-surface.js), qui lit son verbe dans la table des liens.
1107     `poserProjetActif` reste : il pose la SUIVANTE, ce que délier ne fait pas. */
1108  try { window.delierLesSessionsDeLaRealisation('code', p.id) } catch (e) { /* poste absent : poserProjetActif délie déjà la session courante */ }
1109  poserProjetActif(STATE.codeProjects[0]?.id || null)
1110  save(); render()
1111}
1112
1113/* ══════════════════════════════════════════════════════════════════════════
1114 * LES OUVRIERS DE MONACO — LE DÉFAUT QUI EXPLIQUAIT LES TROIS MANQUES
1115 *
1116 * MESURÉ DANS UN VRAI CHROMIUM, avant d'écrire une ligne. Un JSON à virgule
1117 * finale, un JS à parenthèse ouverte, un CSS sans accolade fermante, un Python
1118 * cassé : QUATRE langages, ZÉRO marqueur, et `editor.action.formatDocument`
1119 * non supporté partout. L'erreur nommait la cause :
1120 *
1121 *   Failed to parse URL from /vs/language/typescript/tsWorker.js
1122 *
1123 * Monaco fait son travail de langage dans des WORKERS. Sans `MonacoEnvironment`,
1124 * il fabrique un worker qui résout ses adresses en RELATIF — et un worker n'a
1125 * pas de page pour base. Les workers ne démarraient donc jamais. Ce n'est pas
1126 * « il manque le soulignement des erreurs » : c'est que tout ce que Monaco sait
1127 * faire d'un langage était éteint. Les diagnostics, le formatage, la complétion
1128 * de TypeScript, le survol des types — rien de tout ça ne pouvait exister.
1129 *
1130 * ── LE PIÈGE DU `baseUrl`, ET IL A COÛTÉ UNE MESURE ───────────────────────
1131 *
1132 * Premier jet : `baseUrl` = le dossier `vs`. Le worker a alors cherché
1133 * `/vendor/monaco/vs/language/typescript/…` — il RAJOUTE `vs/` lui-même.
1134 * `baseUrl` désigne le PARENT du dossier `vs`, pas `vs`. Mesuré sur l'erreur
1135 * `importScripts … /vs/vs/language/typescript`.
1136 *
1137 * ── POURQUOI UNE `data:` ET PAS UN FICHIER ────────────────────────────────
1138 *
1139 * Le worker doit être amorcé avec une base ABSOLUE avant d'importer le vrai
1140 * code. Un fichier d'amorce demanderait une route de plus à servir, et il
1141 * devrait connaître l'adresse de Monaco — qui change (route locale, ou CDN si
1142 * elle rend 404). L'amorce est donc construite ici, où cette adresse est
1143 * connue, et passée en `data:`. Elle ne contient que deux instructions, et
1144 * aucune donnée de la personne.
1145 *
1146 * CE QUI EST ÉPROUVÉ, ET CE QUI NE L'EST PAS. Mesuré sur la route locale
1147 * (même origine) : JSON « Trailing comma » ligne 3, JS ligne 3, CSS
1148 * « } expected » ligne 2, Python muet — et le formatage supporté pour les
1149 * trois premiers, refusé pour Python. La bascule CDN n'a PAS été mesurée : un
1150 * `importScripts` d'origine croisée depuis un worker `data:` dépend du CORS du
1151 * CDN et de la politique de la page. Si elle ne passe pas, on retombe
1152 * exactement sur ce qu'on avait avant ce lot — l'éditeur écrit, sans les
1153 * services de langage.
1154 * ══════════════════════════════════════════════════════════════════════════ */
1155function amorceDesOuvriers(adresseVs, base) {
1156  var abs = String(adresseVs || '')
1157  if (!abs) return ''
1158  try { abs = new URL(abs, base || (typeof location !== 'undefined' ? location.href : undefined)).toString() }
1159  catch (e) { return '' }
1160  abs = abs.replace(/\/+$/, '')
1161  // `baseUrl` est le PARENT de `vs` : le worker ajoute `vs/` de lui-même.
1162  var parent = abs.replace(/\/[^/]+$/, '/')
1163  return 'self.MonacoEnvironment={baseUrl:' + JSON.stringify(parent) + '};'
1164    + 'importScripts(' + JSON.stringify(abs + '/base/worker/workerMain.js') + ');'
1165}
1166
1167if (typeof window !== 'undefined') window.brancherLesOuvriersDeMonaco = function (adresseVs) {
1168  /* ON NE REBRANCHE PAS SI QUELQU'UN A DÉJÀ POSÉ SON ENVIRONNEMENT : écraser
1169     le réglage d'un hôte (l'application de bureau, une intégration) casserait
1170     ce qui marche chez lui pour réparer ce qui manquait ici. */
1171  if (window.MonacoEnvironment && window.MonacoEnvironment.getWorkerUrl) return false
1172  var amorce = amorceDesOuvriers(adresseVs)
1173  if (!amorce) return false
1174  /* `blob:` ET PAS `data:` — ET C'EST TOUT CE QUI MANQUAIT.
1175   *
1176   * La CSP que l'application se pose à elle-même dit `worker-src 'self' blob:`
1177   * (`appContentSecurityPolicy`, serve.js). `data:` n'y est PAS. Le navigateur
1178   * refusait donc de créer l'ouvrier, et Monaco retombait exactement là où il
1179   * était avant qu'on le branche : pas de diagnostic, pas de formatage, pas de
1180   * complétion, et l'entrée « Formater le fichier » qui ne s'affiche jamais.
1181   * Le commentaire au-dessus prévoyait le risque sans le mesurer ; c'est
1182   * mesuré maintenant, et ce n'était pas le CORS du CDN, c'était notre propre
1183   * politique.
1184   *
1185   * ET LE BLOB HÉRITE DE L'ORIGINE DE LA PAGE. `importScripts` vers
1186   * `/vendor/monaco/...` — le chemin normal, servi par CRÉA — est donc
1187   * same-origin ; un ouvrier `data:` a une origine opaque et n'aurait de toute
1188   * façon rien pu charger. Sur le repli CDN, l'origine de la page rend la
1189   * requête soumise au CORS, que jsDelivr accorde.
1190   *
1191   * UNE SEULE URL POUR TOUS LES OUVRIERS : Monaco appelle `getWorkerUrl` une
1192   * fois par langage. En fabriquer un blob à chaque appel en laisserait un par
1193   * langage derrière soi, vivants jusqu'au rechargement. */
1194  var urlOuvrier = null
1195  window.MonacoEnvironment = {
1196    getWorkerUrl: function () {
1197      if (urlOuvrier) return urlOuvrier
1198      try {
1199        urlOuvrier = URL.createObjectURL(new Blob([amorce], { type: 'text/javascript' }))
1200        return urlOuvrier
1201      } catch (e) {
1202        /* Pas de `Blob` ni d'`URL.createObjectURL` — un hôte exotique, ou un
1203           banc de garde. On rend l'ancienne forme plutôt que `undefined` :
1204           elle sera peut-être refusée, mais rendre rien ferait tomber Monaco
1205           au lieu de le laisser se replier sur son ouvrier par défaut. */
1206        return 'data:text/javascript;charset=utf-8,' + encodeURIComponent(amorce)
1207      }
1208    },
1209  }
1210  return true
1211}
1212
1213/* ══════════════════════════════════════════════════════════════════════════
1214 * FORMATER LE FICHIER — LA PORTE QUI N'EXISTAIT PAS, ET QUI N'AURAIT RIEN FAIT
1215 *
1216 * `editor.action.formatDocument` est dans Monaco depuis toujours. Aucun geste
1217 * du studio ne l'appelait — et l'eût-il appelée, elle n'aurait rien fait :
1218 * mesuré avant ce lot, `isSupported()` rendait FAUX pour json, javascript, css
1219 * ET python. Le formatage vit dans les workers, et les workers ne démarraient
1220 * pas (voir `amorceDesOuvriers` juste au-dessus). Brancher un bouton sur une
1221 * action morte aurait produit exactement le pire cas du dépôt : une porte qui
1222 * ne dit pas qu'elle ne mène nulle part.
1223 *
1224 * L'ENTRÉE NE S'AFFICHE QUE SI LE LANGAGE SAIT SE FORMATER. C'est Monaco qui
1225 * répond, pas une liste d'extensions écrite ici : une liste vieillirait, et
1226 * `isSupported()` est la question exacte. Mesuré après le branchement des
1227 * ouvriers : VRAI pour json, javascript et css ; FAUX pour python — et c'est
1228 * juste, aucun formateur Python n'est livré. Rien ne s'affiche pour Python, et
1229 * personne n'a besoin de lire un refus.
1230 * ══════════════════════════════════════════════════════════════════════════ */
1231function _actionDeFormatage() {
1232  if (typeof window === 'undefined' || typeof window.editeurDeCode !== 'function') return null
1233  var ed = null
1234  try { ed = window.editeurDeCode() } catch (e) { return null }
1235  if (!ed || typeof ed.getAction !== 'function') return null
1236  try { return ed.getAction('editor.action.formatDocument') || null } catch (e) { return null }
1237}
1238
1239if (typeof window !== 'undefined') window.codeFormatageDisponible = function () {
1240  var a = _actionDeFormatage()
1241  try { return !!(a && typeof a.isSupported === 'function' && a.isSupported()) } catch (e) { return false }
1242}
1243
1244if (typeof window !== 'undefined') window.codeFormaterLeFichier = function () {
1245  var a = _actionDeFormatage()
1246  if (!a || typeof a.run !== 'function') return
1247  /* APRÈS LE FORMATAGE, LE FICHIER EST REPOSÉ. Monaco réécrit son modèle ; le
1248     projet, lui, ne le sait pas. `saveActiveCode` est le geste qui recopie
1249     l'éditeur dans le fichier ET le date — sans lui, une fusion ultérieure
1250     comparerait une date d'avant le formatage et écraserait tout. */
1251  try {
1252    var fin = a.run()
1253    if (fin && typeof fin.then === 'function') {
1254      fin.then(function () { try { saveActiveCode(); save() } catch (e) { /* studio démonté entre-temps */ } })
1255      return
1256    }
1257  } catch (e) { return }
1258  try { saveActiveCode(); save() } catch (e) { /* studio démonté entre-temps */ }
1259}
1260
1261/* ══════════════════════════════════════════════════════════════════════════
1262 * LE RUBAN DES FICHIERS — ON ÉCRIVAIT DANS UN PROJET SANS VOIR SES FICHIERS
1263 *
1264 * L'atelier montrait UN fichier, et rien ne disait qu'il y en avait d'autres.
1265 * Pour en changer il fallait sortir de l'atelier, ouvrir la page Fichiers, et
1266 * revenir : trois gestes pour l'action la plus fréquente de tout le studio.
1267 *
1268 * IL NE CRÉE AUCUN BRANCHEMENT. Les rangées portent `data-code-file="
1268<rang>"`,
1269 * qui est écouté depuis toujours dans `bindEvents()` et déjà rendu par la
1270 * liste de fichiers. C'est l'inverse exact du défaut réparé au lot précédent —
1271 * là il y avait un brancheur sans bouton ; ici il y avait un brancheur, un
1272 * bouton ailleurs, et pas de bouton où l'on travaille.
1273 *
1274 * LE RANG, PAS LE CHEMIN, parce que c'est ce que l'écouteur lit, et parce que
1275 * `STATE.activeCodeFile` est lui-même un rang. Passer par le chemin ferait une
1276 * seconde façon de désigner un fichier, et elles divergeraient au renommage.
1277 *
1278 * ON N'AFFICHE PAS UN RUBAN D'UN SEUL ONGLET : il ne mène qu'à l'endroit où
1279 * l'on est déjà, et il prendrait une rangée de hauteur à l'éditeur. Même règle
1280 * que « Changer de projet » dans le menu du studio.
1281 * ══════════════════════════════════════════════════════════════════════════ */
1282function _nomCourtDeFichier(chemin) {
1283  var s = String(chemin || '')
1284  var i = s.lastIndexOf('/')
1285  return i >= 0 ? s.slice(i + 1) : s
1286}
1287
1288function rubanDesFichiersCode(fichiers, actif) {
1289  var liste = fichiers || []
1290  if (liste.length < 2) return ''
1291  var rang = Number(actif)
1292  if (!Number.isFinite(rang)) rang = 0
1293  var lignes = []
1294  for (var i = 0; i < liste.length; i++) {
1295    var f = liste[i] || {}
1296    var chemin = String(f.path || f.name || '')
1297    var court = _nomCourtDeFichier(chemin) || 'sans nom'
1298    /* LE CHEMIN COMPLET EN INFOBULLE : deux `index.js` dans deux dossiers
1299       portent le même nom court, et le ruban seul ne les distinguerait pas. */
1300    lignes.push('<button class="code-onglet' + (i === rang ? ' est-actif' : '') + '"'
1301      + ' data-code-file="' + i + '"'
1302      + ' title="' + escHtml(chemin) + '">'
1303      + escHtml(court) + '</button>')
1304  }
1305  return '<div class="code-onglets" role="tablist" aria-label="Fichiers du projet">' + lignes.join('') + '</div>'
1306}
1307if (typeof window !== 'undefined') window.rubanDesFichiersCode = rubanDesFichiersCode
1308
1309/* ══════════════════════════════════════════════════════════════════════════
1310 * LE PROJET EST UN PROJET, PAS UNE PILE DE FICHIERS SÉPARÉS
1311 *
1312 * CE QUI ÉTAIT FAUX. `monaco.editor.create(host, { value: … })` fabrique un
1313 * modèle ANONYME — une adresse `inmemory://model/1` tirée au compteur. Aucun
1314 * `createModel`, aucun `monaco.Uri` nulle part dans le dépôt. Le worker
1315 * TypeScript voyait donc chaque fichier SEUL au monde :
1316 *
1317 *   · « aller à la définition » d'une fonction importée ne menait nulle part ;
1318 *   · un `import './outils.js'` ne résolvait rien, donc aucune erreur d'import
1319 *     n'était jamais signalée ;
1320 *   · renommer un symbole ne pouvait pas suivre ses usages ;
1321 *   · et le modèle étant refait à CHAQUE rendu — une notification suffit —
1322 *     l'historique d'annulation repartait de zéro plusieurs fois par minute.
1323 *
1324 * MESURÉ DANS CHROMIUM, deux fichiers et un import entre eux :
1325 *   getDefinitionAtPosition  → file:///projet/outils.js      (ça traverse)
1326 *   getReferencesAtPosition  → app.js ET outils.js           (ça traverse)
1327 *   marqueur ligne 4         → « Cannot find name 'manquante' »
1328 * Avant : rien de tout ça, parce qu'il n'y avait pas d'adresses.
1329 *
1330 * ── LES DEUX RÉGLAGES QU'IL A FALLU MESURER ───────────────────────────────
1331 *
1332 * L'URI seule ne suffit pas. Deux options sont FAUSSES par défaut pour du
1333 * JavaScript, et chacune a été vérifiée à part :
1334 *   · `checkJs` — sans lui, `manquante(1)` sur une fonction qui n'existe pas
1335 *     ne dit rien du tout ;
1336 *   · `noSemanticValidation: false` — avec `checkJs` seul, le worker TROUVE
1337 *     l'erreur mais elle n'arrive JAMAIS à l'écran. Mesuré : le diagnostic
1338 *     existait dans le worker et la liste des marqueurs restait à trois
1339 *     avertissements de variable inutilisée.
1340 *
1341 * ── L'ADRESSE PORTE LE PROJET, ET CE N'EST PAS DÉCORATIF ──────────────────
1342 *
1343 * Deux projets ont chacun leur `index.js`. Sous une adresse qui ne porterait
1344 * que le chemin, ils seraient LE MÊME fichier pour le worker, et le second
1345 * écraserait le premier.
1346 *
1347 * ── ON JETTE LES MODÈLES DES AUTRES PROJETS, ET C'EST UN ARBITRAGE ────────
1348 *
1349 * Les garder ferait résoudre un `import './outils.js'` du projet A vers le
1350 * `outils.js` du projet B : une vérité fausse, pire qu'une absence. CE QU'ON
1351 * PERD : l'historique d'annulation d'un projet qu'on quitte. On le dit plutôt
1352 * que de le cacher — et c'est déjà infiniment plus que ce qu'il y avait, où
1353 * l'historique tombait à chaque rendu.
1354 * ══════════════════════════════════════════════════════════════════════════ */
1355
1356/* Le préfixe d'un projet dans l'espace des adresses. `encodeURIComponent` sur
1357   chaque tronçon : un nom de dossier avec un espace ou un « # » fabriquerait
1358   une adresse que Monaco ne relit pas comme on l'a écrite. */
1359function uriDuFichierCode(idProjet, chemin) {
1360  var p = String(idProjet || 'projet')
1361  var c = String(chemin || '')
1362  if (!c) return ''
1363  var tronçons = c.split('/').filter(function (x) { return x && x !== '.' && x !== '..' })
1364  if (!tronçons.length) return ''
1365  return 'file:///' + encodeURIComponent(p) + '/' + tronçons.map(encodeURIComponent).join('/')
1366}
1367
1368var _langagesRegles = false
1369
1370/** Une fois par page. Sans ces deux réglages, les adresses ne servent à rien :
1371    mesuré, l'erreur existe dans le worker et n'atteint jamais l'écran. */
1372function reglerLesLangagesDeMonaco(m) {
1373  if (_langagesRegles || !m || !m.languages || !m.languages.typescript) return false
1374  var ts = m.languages.typescript
1375  var options = {
1376    target: ts.ScriptTarget.ESNext,
1377    module: ts.ModuleKind.ESNext,
1378    moduleResolution: ts.ModuleResolutionKind.NodeJs,
1379    allowJs: true,
1380    checkJs: true,
1381    allowNonTsExtensions: true,
1382    noEmit: true,
1383  }
1384  var diagnostics = { noSemanticValidation: false, noSyntaxValidation: false }
1385  try {
1386    ts.javascriptDefaults.setCompilerOptions(options)
1387    ts.javascriptDefaults.setDiagnosticsOptions(diagnostics)
1388    ts.typescriptDefaults.setCompilerOptions(options)
1389    ts.typescriptDefaults.setDiagnosticsOptions(diagnostics)
1390  } catch (e) { return false }
1391  _langagesRegles = true
1392  return true
1393}
1394
1395/* CE QUE LA SYNCHRONISATION DOIT FAIRE, ET DANS CET ORDRE :
1396     1. donner un modèle à chaque fichier du projet, à son adresse ;
1397     2. REPOSER le contenu quand il a changé HORS de l'éditeur — Créa qui écrit,
1398        une version qu'on remonte. On ne repose que si le texte diffère
1399        vraiment : `setValue` efface l'historique d'annulation, et le faire à
1400        chaque rendu le remettrait à zéro comme avant ce lot ;
1401     3. jeter ce qui n'est plus là — un fichier supprimé ou renommé dont le
1402        modèle survit continue de résoudre les imports des autres. */
1403function synchroniserLesModelesDeCode(m, projet, langueDe) {
1404  if (!m || !m.editor || !projet) return null
1405  var fichiers = projet.files || []
1406  /* ON REND UNE TABLE ADRESSE → MODÈLE, et pas un tableau au rang du fichier :
1407     `getCodeFile()` retombe sur `files[0]` quand le rang sort du tableau, et
1408     lire les modèles au même rang aurait alors donné le modèle d'un AUTRE
1409     fichier que celui affiché. L'adresse est le seul lien exact. */
1410  var parAdresse = Object.create(null)
1411  var vivants = Object.create(null)
1412
1413  for (var i = 0; i < fichiers.length; i++) {
1414    var f = fichiers[i] || {}
1415    var chemin = String(f.path || f.name || '')
1416    var adresse = uriDuFichierCode(projet.id, chemin)
1417    if (!adresse) continue
1418    vivants[adresse] = true
1419    var contenu = String(f.content == null ? '' : f.content)
1420    var uri = m.Uri.parse(adresse)
1421    var modele = m.editor.getModel(uri)
1422    if (!modele) {
1423      modele = m.editor.createModel(contenu, langueDe ? langueDe(chemin) : undefined, uri)
1424    } else if (modele.getValue() !== contenu) {
1425      modele.setValue(contenu)
1426    }
1427    parAdresse[adresse] = modele
1428  }
1429
1430  /* LES MODÈLES QUI NE SONT PLUS À PERSONNE. Ceux de CE projet dont le fichier
1431     est parti (supprimé, renommé) ET ceux de tout autre projet — voir
1432     l'arbitrage plus haut. Les deux cas se disent d'une seule façon : n'est
1433     vivant que ce que le projet ouvert nomme à l'instant.
1434     ON NE TOUCHE QUE `file:///` : les modèles anonymes (`inmemory://`) sont
1435     ceux d'autres surfaces de l'application, et ils ne nous appartiennent pas. */
1436  var tous = m.editor.getModels() || []
1437  for (var k = 0; k < tous.length; k++) {
1438    var mod = tous[k]
1439    var s = mod && mod.uri ? mod.uri.toString() : ''
1440    if (s.indexOf('file:///') !== 0 || vivants[s]) continue
1441    try { mod.dispose() } catch (e) { /* déjà jeté */ }
1442  }
1443
1444  return parAdresse
1445}
1446
1447if (typeof window !== 'undefined') {
1448  window.uriDuFichierCode = uriDuFichierCode
1449  window.reglerLesLangagesDeMonaco = reglerLesLangagesDeMonaco
1450  window.synchroniserLesModelesDeCode = synchroniserLesModelesDeCode
1451}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.