SLOPSHOPPER

files-board

Navigateur de fichiers dans Claude Code CLI (/files), comme celui de l'app desktop : arborescence, recherche de fichiers, onglets d'aperçu, éditeur avec…

paneguardcommandtoastpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · files-board
│ ┃ Fichiers ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./browser.tsx │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /files │ ⎿ files-board: Navigateur de fichiers ouvert. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Fichiers
▣ client module ./browser.tsx
README

files-board

Navigateur de fichiers pour Claude Code CLI, à la manière de celui de l'app desktop : arborescence, recherche de fichiers, onglets d'aperçu, éditeur avec enregistrement automatique, menus contextuels et options d'affichage.

  • CLI seulement. Dans l'app desktop, VS Code ou sur mobile, /files répond que le navigateur est réservé au terminal : l'app desktop a déjà le sien.
  • Souris et clavier. Clic, double clic, clic droit, clic du milieu, glisser ; le clavier atteint le navigateur après un clic dedans, Échap le rend au prompt.
  • Il suit les fichiers. Les onglets et l'arborescence se relisent après chaque outil d'écriture de Claude, et toutes les 4 s tant que le panneau est ouvert. Un fichier que Claude vient de modifier porte ◆, ses dossiers ◇, jusqu'à votre message suivant.

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


Sommaire

  1. Installation
  2. Utilisation
  3. Le panneau
  4. Les options du desktop et leur équivalent
  5. Souris et clavier
  6. Commandes
  7. Données et rafraîchissement
  8. Architecture
  9. Développement
  10. Limites connues

Installation

Le mod est distribué par la marketplace mathis-plugins (dépôt GitHub matwi5/mathis-plugins, source dans plugins/files-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 files-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 et l'élément Client (région interactive) : version 2.1.289 ou plus récente.
  • L'affichage plein écran du terminal (par défaut) : sur l'affichage principal (CLAUDE_CODE_NO_FLICKER=0, tmux), la souris n'atteint pas le navigateur.
  • Facultatifs : git (recherche de fichiers plus rapide, .gitignore respecté), code ou cursor dans le PATH (« Ouvrir dans »).

Utilisation

  • /files-board:files (ou /files) ouvre le panneau sur le dossier de la session.
  • /files <dossier> parcourt un autre dossier ; /files <fichier> ouvre ce fichier et le montre dans l'arborescence.
  • /files close ferme le panneau ; /files toggle l'ouvre ou le ferme.
  • Le bouton « Fichiers » du pied du prompt (mod session-tools) fait la même bascule, sans ligne dans la conversation.
  • Cliquer dans le panneau lui donne le clavier ; Échap le rend au prompt.

Le panneau

≡  app.ts ×   README.md ●                                         ⌕ ⋮ □ ×
 ⌕ Rechercher des fichiers   │ src / app.ts
   .gitignore                │ 1 export const x = 1
 ▸ docs                      │ 2 function f() {
   README.md                 │ 3 │ return "s"
 ▾ src                       │ 4 }
     app.ts                  │
                             │   Ln 2, Col 7 · Espaces : 2 · LF · TypeScript · ✓ Enregistrement auto
  1. La barre du haut. ≡ montre ou masque l'arborescence. Les onglets : l'actif en inverse, un aperçu en italique, ● quand il a des modifications, ◆ (magenta) quand Claude l'a modifié depuis votre dernier message, sinon × ; un clic sur ce signe ferme l'onglet. ‹ et › font défiler les onglets qui ne tiennent pas. À droite : ⌕ rechercher dans le fichier, ⋮ options d'affichage, □ agrandir (▣ une fois agrandi), × fermer le panneau.
  2. L'arborescence. Dossiers et fichiers mêlés, sans tenir compte de la casse, comme dans l'app desktop. Le fichier actif est en couleur, un fichier modifié porte ●. Un fichier que Claude a modifié (Edit, Write, MultiEdit, NotebookEdit) porte ◆ en magenta, et chacun de ses dossiers parents ◇, pour le retrouver même replié. En haut, la case « Rechercher des fichiers » : une recherche floue sur les chemins du dossier ; trop de résultats affichent « Certaines entrées peuvent ne pas être affichées. Continuez à saisir pour affiner. ».
  3. Le fil d'Ariane (docs / specs / flash-bets.md) : un clic sur un dossier le montre dans l'arborescence. Pour un fichier Markdown, </> passe au source et « Aperçu » revient au rendu. Quand Claude a modifié le fichier, le fil d'Ariane ajoute « ◆ Rechargé : modifié par Claude » si l'onglet ouvert vient d'être relu, « ◆ Modifié par Claude » s'il a été ouvert après coup.
  4. L'éditeur. Numéros de ligne, coloration syntaxique (JS/TS, JSON, Python, shell, PowerShell, CSS, HTML/XML, Markdown, YAML/TOML/INI, SQL, langages de la famille C), curseur, barre de défilement. Un fichier Markdown s'ouvre rendu, comme dans l'app desktop.
  5. Les bannières, au-dessus du texte quand il le faut : la barre de recherche dans le fichier (n/m, ↑, ↓, ×), « Modifié sur le disque pendant vos modifications » ([Recharger], [Garder les miennes]), « n'est pas enregistré » à la fermeture d'un onglet modifié ([Enregistrer], [Ne pas enregistrer], [Annuler]).
  6. La ligne d'état : position du curseur, indentation détectée, fin de ligne (LF ou CRLF, gardée à l'enregistrement), langage, état d'enregistrement.

Sous 70 colonnes, le panneau montre l'arborescence ou le fichier, pas les deux : ouvrir un fichier passe au fichier, Tab revient à l'arborescence.


Les options du desktop et leur équivalent

Menu ⋮ (gardé d'une session à l'autre) :

OptionEffet
Afficher l'arborescence de fichiers (Ctrl+Maj+Y)Montre ou masque l'arborescence ; ≡ fait de même.
Onglets d'aperçuUn simple clic ouvre un aperçu (italique) que le suivant remplace ; double clic, modification ou « Garder ouvert » l'épinglent. Décochée : chaque fichier garde son onglet.
Enregistrement automatiqueEnregistre une seconde après la dernière frappe. Décochée : Ctrl+S ; le brouillon est gardé si le panneau se ferme.
La touche Tab indenteTab indente (Maj+Tab désindente) ; décochée, Tab passe de la recherche à l'arborescence puis à l'éditeur.
Retour à la ligne automatiqueReplie les longues lignes aux espaces ; décochée, défilement horizontal qui suit le curseur.
Repères d'indentationUn │ à chaque cran d'indentation.
Afficher les espaces· pour un espace, → pour une tabulation.
Défilement au-delà de la finLa dernière ligne peut monter en haut de l'éditeur.
Taille de tabulation › 2, 4, 8Largeur d'une tabulation, et cran d'indentation d'un fichier qui n'en a pas encore.

Clic droit sur un fichier ou un dossier : Joindre comme contexte · Copier › (Chemin absolu, Chemin relatif, Nom du fichier) · Ouvrir dans › · Afficher dans l'Explorateur (le Finder sur macOS) · Copier le contenu du fichier (fichiers seulement).

Clic droit sur un onglet (ou dans l'éditeur) : les mêmes, plus Afficher dans l'arborescence de fichiers · Rechercher dans le fichier (Ctrl+F) · Recharger le fichier · Garder ouvert (aperçu seulement) · Fermer le fichier.

ActionDans le CLI
Joindre comme contexteInsère @chemin au curseur du prompt (@dossier/ pour un dossier, entre guillemets s'il y a une espace).
CopierPresse-papiers du terminal (outil du système ou OSC 52).
Ouvrir dansApplication par défaut ; Visual Studio Code et Cursor s'ils sont dans le PATH ; Bloc-notes (Windows) ou TextEdit (macOS).
Afficher dans l'Explorateurexplorer /select, sous Windows, open -R sous macOS, le dossier parent avec xdg-open ailleurs.
Recharger le fichierRelit le disque et abandonne les modifications non enregistrées.

Souris et clavier

Souris

  • Clic : déplie un dossier, ouvre un fichier en aperçu, place le curseur, choisit une entrée de menu.
  • Double clic : ouvre un fichier pour de bon (épinglé) ; dans l'aperçu Markdown, passe au source à cet endroit.
  • Clic droit : menu de l'entrée, de l'onglet ou du fichier ouvert.
  • Clic du milieu sur un onglet : le ferme.
  • Glisser le séparateur │ : largeur de l'arborescence (gardée). Glisser une barre de défilement : défile.
  • Molette : relayée à la zone sous le pointeur (à vérifier dans un vrai terminal, voir les limites).

Clavier (après un clic dans le panneau)

ToucheEffet
Ctrl+SEnregistrer
Ctrl+FRechercher dans le fichier (encore une fois : fermer la barre)
Ctrl+PAller à la recherche de fichiers
Ctrl+WFermer l'onglet
Ctrl+PageSuiv / Ctrl+PagePrécOnglet suivant / précédent
Ctrl+Maj+YMontrer ou masquer l'arborescence
Maj+F10Menu de la ligne ou de l'onglet choisi
Tab / Maj+TabIndenter / désindenter, ou changer de zone (voir l'option)
ÉchapRendre le clavier au prompt
  • Arborescence : ↑ ↓ PageSuiv PagePréc Début Fin pour choisir, → déplie, ← replie ou remonte au dossier parent, Entrée ouvre (et passe à l'éditeur), Espace ouvre en aperçu. Une lettre tapée lance la recherche.
  • Recherche de fichiers : ↑ ↓ choisissent, Entrée ouvre le fichier ou montre le dossier dans l'arborescence, Ctrl+U efface.
  • Éditeur : flèches, Ctrl+← →, Début (au premier caractère, puis en début de ligne), Fin, Ctrl+Début/Fin, PageSuiv/PagePréc ; Entrée garde l'indentation (un cran de plus après {, [, (, : en Python) ; Retour arrière recule d'un cran dans l'indentation ; Ctrl+Retour arrière efface un mot ; Ctrl+Z annule, Ctrl+Y ou Ctrl+Maj+Z rétablit.
  • Menus : ↑ ↓, → ouvre un sous-menu, ← le referme, Entrée choisit ; un clic hors du menu le ferme.
  • Fermeture d'un onglet modifié : Entrée enregistre, n n'enregistre pas, une autre touche annule.

Commandes

CommandeEffet
/files-board:filesOuvre le panneau. Connue dès le premier message.
/filesLa même, déclarée au démarrage du mod. Masquée du menu, utilisable tapée en entier.
/files <dossier>Parcourt ce dossier (relatif au dossier de la session, absolu, ou ~/…).
/files <fichier>Ouvre ce fichier et le montre dans l'arborescence.
/files closeFerme le panneau.
/files toggleOuvre le panneau s'il est fermé, le ferme sinon. Lancée par un autre plugin (bouton « Fichiers »), elle n'écrit rien dans la conversation.

Données et rafraîchissement

DonnéeEmplacementPortée
Options du menu ⋮, largeur de l'arborescence$.store du pluginToutes les sessions
Dossier parcouru, dossiers dépliés, onglets, brouillons, recherche, fichiers modifiés par ClaudeÉtat de session ($.state)Une discussion
Curseur, défilement, annulations, menusLa région elle-mêmeTant que le panneau est ouvert
LectureQuand
DossierÀ son dépliage ; relu après un outil d'écriture de Claude (400 ms après le dernier) et toutes les 4 s panneau ouvert
Fichier ouvertÀ son ouverture ; relu quand sa date ou sa taille change (mêmes moments)
Index de rechercheÀ la première recherche : git ls-files dans un dépôt, sinon un parcours du disque (30 000 fichiers au plus, sans node_modules, .git, dist…) ; refait après un outil d'écriture

Un fichier changé sur le disque se relit tout seul ; pendant des modifications, la bannière « Modifié sur le disque » laisse choisir. Relu parce que Claude vient de le modifier, il le dit dans le fil d'Ariane (« ◆ Rechargé : modifié par Claude »).

Marques « modifié par Claude » (◆ sur le fichier et son onglet, ◇ sur ses dossiers) :

QuoiComment
PoséeAprès un Edit, Write, MultiEdit ou NotebookEdit réussi, sur le fichier de l'appel (file_path, notebook_path), sous-agents compris. Pas après Bash ni PowerShell, qui ne nomment pas de fichier : relecture seule.
EffacéeAu message suivant de la personne (tapé, ou par Remote Control). Une commande (/files…) ou un message envoyé par un plugin ne l'efface pas.
GardéeÉtat de session ($.state), chemins absolus, 200 au plus : elle suit un changement de dossier parcouru.

Architecture

files-board/
├── .claude-plugin/plugin.json   manifeste (nom, version, contrat de types)
├── .claude-plugin/types/        types du moteur, pour tsconfig.json
├── commands/files.md            /files-board:files, connue avant le démarrage du mod
├── hooks/
│   ├── hooks.json               déclare le module register.tsx
│   ├── register.tsx             le seul à parler au moteur : état, disque, presse-papiers, prompt, applications
│   ├── browser.tsx              la région interactive (élément Client) : dessin, clavier, souris, éditeur, menus
│   └── model.ts                 données pures : chemins, tri, recherche floue, colonnes, coloration, édition, menus
├── types/index.d.ts             contrat de l'état (`PluginState`) et des messages
├── tsconfig.json
└── tests/                       `claude plugin test` : le panneau, la région, le modèle
  • Deux côtés. La région (browser.tsx) tourne dans le fil de dessin, sans $. Elle demande tout le reste au module des hooks par surface.post (événement ui.message), un envoi par image au plus.
  • Demandes numérotées. Chaque demande porte un numéro ; la région renvoie celles qui ne sont pas accusées, le module ignore celles déjà faites (seen). Une recherche, un brouillon ou une fenêtre de lignes remplacent leur demande précédente encore en file.
  • Props bornées. Les données de la région passent par ses props, bornées à 100 000 caractères : au-delà de 60 000 caractères sérialisés, un fichier est lu par fenêtres de lignes, en lecture seule ; l'arborescence envoyée est plafonnée à 1 200 lignes.
  • Molette. La région ne reçoit pas la molette : le module la prend sur le panneau (ui.scroll) et la lui relaie.

Développement

claude plugin validate plugins/files-board
claude plugin test plugins/files-board
  • Lancer les commandes depuis un chemin court (~) : depuis un dossier au chemin trop long, Windows refuse le dossier courant.
  • Les tests simulent le disque, le prompt et le presse-papiers (world). Le moteur de test rend /w en C:\w sous Windows : la simulation ramène les chemins à /w.
  • Le temps de la région avance avec sa minuterie (ui.advance dans les tests), pas avec l'horloge réelle.
  • Vérification de types : tsc -p plugins/files-board avec TypeScript 5.4 ou plus récent (le tsconfig.json autorise les imports ./model.ts). Sans tsc installé, l'API typescript.js livrée avec VS Code fait l'affaire.

Limites connues

  • Plein écran requis pour la souris. Sur l'affichage principal du terminal, les clics n'atteignent pas la région ; le panneau le signale.
  • Le clavier après un clic. La région reçoit les touches une fois cliquée : c'est le chemin sûr.
  • À vérifier dans un vrai terminal (la 0.2.0 n'a été essayée que par claude plugin test et un chargement claude -p) :
  • la molette : elle dépend de l'événement ui.scroll du panneau, que le moteur ne promet pas quand le contenu tient exactement dans le cadre ; sinon, glisser la barre de défilement ou PageSuiv/PagePréc ;
  • le focus d'emblée : /files demande $.ui.focus sur la région, sans garantie que le moteur l'accepte ;
  • la largeur des symboles (≡, ⌕, ⋮, ▐, ⚠) dans Windows Terminal : une rangée décalée viendrait d'un écart entre la largeur comptée par le mod et celle du terminal.
  • Pas de sélection de texte dans l'éditeur : copier tout le fichier passe par « Copier le contenu du fichier ».
  • Gros fichiers en lecture seule (au-delà d'environ 60 000 caractères), par fenêtres de 600 lignes ; plus de 4 Mio : pas d'ouverture.
  • Pas d'aperçu des images ni des binaires : un bouton les ouvre dans l'application par défaut.
  • Coloration du mod, approximative, aux couleurs d'un thème sombre.
  • Aperçu Markdown par blocs : il défile paragraphe par paragraphe ; les liens relatifs n'y sont pas cliquables.
  • Raccourcis du terminal. Ctrl+Maj+Y suppose un terminal qui transmet Maj avec Ctrl ; certains terminaux gardent Ctrl+W ou Ctrl+S pour eux.
Source 4 files
hooks/register.tsx 919 lines
1// Navigateur de fichiers (/files) pour Claude Code CLI, à la manière de celui de l'app desktop :
2// arborescence, recherche de fichiers, onglets d'aperçu, éditeur, menus contextuels, options.
3//
4// - Ce module est le seul à parler au moteur (`$`) : état, fichiers, presse-papiers, prompt,
5//   applications externes. La région interactive (browser.tsx) dessine et lit le clavier et la
6//   souris ; elle demande tout le reste par messages (`ui.message`), accusés par `seen`.
7// - Terminal seulement : ailleurs (app desktop, VS Code, mobile), le panneau le dit en une ligne.
8// - `/files-board:files` (commands/files.md) est connue dès le premier message ; `/files`,
9//   déclarée au démarrage, sort du menu mais reste utilisable tapée en entier.
10
11import { atom, read, update } from 'claude-code'
12import type { Register } from 'claude-code'
13
14import type { App, Doc, DocView, Entry, Find, Listing, Op, OptionKey, Options, Os, Platform, Post, Reveal, Seen, Tab, View, Wheel } from '../types'
15import {
16  DEFAULT_OPTIONS,
17  EDIT_LIMIT,
18  MAX_ENTRIES,
19  MAX_INDEX,
20  MAX_READ,
21  WINDOW_CHARS,
22  WINDOW_LINES,
23  ancestors,
24  baseName,
25  clamp,
26  compareNames,
27  flattenRows,
28  folderName,
29  joinRel,
30  mention,
31  normalizeOptions,
32  relFrom,
33  resolveFrom,
34  searchPaths,
35  toAbs,
36} from './model.ts'
37
38const PANE = 'files'
39const TITLE = 'Fichiers'
40/** La commande déclarée par `commands/files.md`, préfixée du nom du plugin. */
41const STATIC_FILES = 'files-board:files'
42const ARG_HINT = '[dossier|fichier|toggle|close]'
43/** La clé de la région dans le panneau : `$.ui.focus` la vise. */
44const CLIENT_KEY = 'browser'
45
46const EMPTY_FIND: Find = { query: '', results: [], total: 0, isLoading: false }
47
48// Gardés par l'hôte : survivent au rechargement à chaud du module.
49const rootAtom = atom({ plugin: 'files-board', key: 'root' } as const, null as string | null)
50const expandedAtom = atom({ plugin: 'files-board', key: 'expanded' } as const, [] as string[])
51const listingsAtom = atom({ plugin: 'files-board', key: 'listings' } as const, {} as Record<string, Listing>)
52const tabsAtom = atom({ plugin: 'files-board', key: 'tabs' } as const, [] as Tab[])
53const activeAtom = atom({ plugin: 'files-board', key: 'active' } as const, null as string | null)
54const docsAtom = atom({ plugin: 'files-board', key: 'docs' } as const, {} as Record<string, Doc>)
55const findAtom = atom({ plugin: 'files-board', key: 'find' } as const, EMPTY_FIND)
56const optionsAtom = atom({ plugin: 'files-board', key: 'options' } as const, DEFAULT_OPTIONS)
57const seenAtom = atom({ plugin: 'files-board', key: 'seen' } as const, { inst: '', seq: 0 } as Seen)
58const wheelAtom = atom({ plugin: 'files-board', key: 'wheel' } as const, { n: 0, by: 0, column: 0, row: 0 } as Wheel)
59const revealAtom = atom({ plugin: 'files-board', key: 'reveal' } as const, { path: '', n: 0 } as Reveal)
60const maximizedAtom = atom({ plugin: 'files-board', key: 'isMaximized' } as const, false)
61const platformAtom = atom({ plugin: 'files-board', key: 'platform' } as const, { os: 'linux', sep: '/', apps: [], isReady: false } as Platform)
62const touchedAtom = atom({ plugin: 'files-board', key: 'touched' } as const, [] as string[])
63
64// Outils après lesquels des fichiers ont pu changer.
65const WRITERS = new Set(['Bash', 'PowerShell', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
66/** Plafond des fichiers marqués « modifié par Claude » : les props de la région sont bornées. */
67const MAX_TOUCHED = 200
68// Dossiers sautés quand la recherche parcourt le disque (hors dépôt git).
69const SKIP_DIRS = new Set(['.git', 'node_modules', '.venv', 'venv', '__pycache__', '.next', 'dist', 'build', 'target', '.cache'])
70
71// État du module, perdu au rechargement : rien que des caches.
72let chain: Promise<void> = Promise.resolve()
73let fileIndex: { root: string; paths: string[] } | null = null
74/** Les lignes des fichiers trop gros pour la région, lues par fenêtres. */
75const bigDocs = new Map<string, string[]>()
76let refreshTimer: { cancel: () => void } | null = null
77let viewport = { columns: 120, rows: 40 }
78let optionsLoaded = false
79
80type Run = { exitCode: number; stdout: string; stderr: string }
81
82/** Un effet raté (pas d'écran, surface sans panneau, prompt occupé) ne doit rien emporter. */
83async function quietly<T>(effect: () => T | Promise<T>): Promise<T | undefined> {
84  try {
85    return await effect()
86  } catch {
87    return undefined
88  }
89}
90
91const short = (err: unknown) => String((err as { message?: unknown })?.message ?? err).split('\n')[0]?.slice(0, 200) ?? ''
92const toast = ($: any, text: string) => quietly(() => $.ui.toast(text))
93
94// ─── Commande /files ────────────────────────────────────────────────────────
95
96const answerFiles = async ($: any, e: { args: string; origin?: { kind: string }; presentation?: { columns: number; isFullscreen: boolean } }) => {
97  const surfaces: readonly string[] = (await quietly(() => $.session.surfaces())) ?? []
98  if (!surfaces.includes('terminal')) {
99    return { text: "Le navigateur de fichiers files-board est réservé à Claude Code CLI (terminal) : l'app desktop a déjà le sien." }
100  }
101  // Lancée par un autre plugin (le bouton « Fichiers » du pied du prompt) : pas de ligne dans
102  // la conversation, ni de texte pour le modèle.
103  const isQuiet = e.origin?.kind === 'plugin'
104  let arg = e.args.trim()
105  if (/^toggle$/i.test(arg)) {
106    const panes: readonly { id: string }[] = (await quietly(() => $.ui.panes())) ?? []
107    if (panes.some((p) => p.id === PANE)) arg = 'close'
108    else arg = ''
109  }
110  if (/^(close|fermer)$/i.test(arg)) {
111    await quietly(() => $.ui.close({ id: PANE }))
112    return isQuiet ? {} : { text: 'Navigateur de fichiers fermé.' }
113  }
114  await loadOptions($)
115  const p = await quickPlatform($)
116  let text = 'Navigateur de fichiers ouvert.'
117  if (arg) {
118    const cwd: string = await $.session.cwd()
119    let target = arg
120    if (/^~([\\/]|$)/.test(arg)) {
121      const home = (await quietly(() => $.env.get('USERPROFILE'))) ?? (await quietly(() => $.env.get('HOME')))
122      if (home) target = `${home}${arg.slice(1)}`
123    }
124    const abs = resolveFrom(cwd, target, p.sep)
125    const stat = await quietly(() => $.fs.stat(abs))
126    if (!stat) return { text: `Introuvable : ${abs}` }
127    if (stat.kind === 'dir') {
128      await setRoot($, abs)
129      text = `Navigateur de fichiers ouvert sur ${abs}.`
130    } else {
131      let rel = relFrom(await rootOf($), abs, p.os)
132      if (rel === null) {
133        await setRoot($, abs.slice(0, Math.max(abs.lastIndexOf('\\'), abs.lastIndexOf('/'))) || abs)
134        rel = relFrom(await rootOf($), abs, p.os) ?? baseName(abs.replace(/\\/g, '/'))
135      }
136      await openFile($, rel, false)
137      await revealInTree($, rel)
138      text = `${rel} ouvert dans le navigateur de fichiers.`
139    }
140  }
141  await ensureListings($)
142  if (e.presentation) viewport = { ...viewport, columns: e.presentation.columns }
143  await quietly(() => $.ui.open({ id: PANE, title: TITLE, focus: true, ...paneSize(false) }))
144  // La région prend le clavier après un clic ; on essaie aussi de lui passer le focus d'emblée.
145  const focused = await quietly(() => $.ui.focus({ requestId: PANE, key: CLIENT_KEY }))
146  if (focused?.deny) await quietly(() => $.ui.log(`focus de la région refusé : ${focused.deny}`, { to: 'debug' }))
147  if (e.presentation && !e.presentation.isFullscreen) {
148    text += " Affichage principal : la souris n'atteint pas le navigateur ; l'affichage plein écran (par défaut) est conseillé."
149  }
150  // Les applications de « Ouvrir dans » se cherchent en arrière-plan.
151  $.clock.after(0, () => void platform($))
152  return isQuiet ? {} : { text }
153}
154
155// `/files` reste utilisable tapée en entier, mais sort du menu : `/files-board:files`, déjà là
156// avant le premier message, y suffit et le doublon disparaît.
157const hideDuplicate = async ($: any, e: any, next: any) => ({ ...(await next(e)), isHidden: true })
158
159export const register: Register = (on) => {
160  on('session.start', async ($, e, next) => {
161    const result = await next(e)
162    await quietly(() =>
163      $.command.register({
164        name: 'files',
165        description: 'Navigateur de fichiers : arborescence, recherche, onglets, éditeur',
166        argumentHint: ARG_HINT,
167      }),
168    )
169    await loadOptions($)
170    // Toutes les 4 s, panneau ouvert : les fichiers changés hors de la session.
171    $.clock.every(4000, () => void watch($))
172    return result
173  })
174
175  on('command.run', { command: 'files' }, answerFiles)
176  on('command.describe', { command: 'files' }, hideDuplicate)
177  // En panne, le mod s'efface : la commande statique dit alors qu'il n'est pas actif.
178  on('command.run', { command: STATIC_FILES }, answerFiles).catch(() => undefined)
179
180  // Claude modifie des fichiers : les onglets et l'arborescence se relisent juste après. Le
181  // fichier nommé par l'appel (Edit, Write, MultiEdit, NotebookEdit) est marqué avant la relecture,
182  // qui sait ainsi que c'est Claude ; Bash et PowerShell ne nomment pas de fichier : relecture seule.
183  on('tool.call', async ($, e, next) => {
184    const result = await next(e)
185    if (!WRITERS.has(String(e.tool))) return result
186    const r = result as { deny?: unknown; isError?: unknown } | undefined
187    if (!r?.deny && !r?.isError) await quietly(() => markTouched($, e as unknown as Record<string, unknown>))
188    schedule($)
189    return result
190  }).catch(() => undefined)
191
192  // Les marques « modifié par Claude » durent jusqu'au message suivant de la personne (tapé ou
193  // envoyé par Remote Control) ; une commande comme /files ne les efface pas.
194  on('prompt.submit', async ($, e, next) => {
195    const kind = e.origin?.kind
196    if ((kind === 'composer' || kind === 'bridge') && !e.command) await quietly(() => clearTouched($))
197    return next(e)
198  }).catch(() => undefined)
199
200  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
201    const elements = $.ui.resolve(e) as any
202    const { Box, Text } = elements
203    if (e.surface !== 'terminal') {
204      return <Text dimColor>Le navigateur de fichiers files-board est réservé à Claude Code CLI (terminal).</Text>
205    }
206    if (e.viewport) viewport = { columns: e.viewport.columns, rows: e.viewport.rows }
207    const view = await buildView($, e.props.bodyColumns, e.props.scroll.bodyRows, e.viewport?.isFullscreen !== false)
208    const { Client } = elements
209    return (
210      <Box flexDirection="column">
211        <Client key={CLIENT_KEY} module="./browser.tsx" props={view} width={e.props.bodyColumns} height={e.props.scroll.bodyRows} />
212      </Box>
213    )
214  })
215
216  // La molette sur le panneau : relayée à la région, qui défile elle-même (le panneau, lui, tient
217  // tout entier dans son cadre et n'a rien à faire défiler).
218  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
219    if (e.origin.kind !== 'person' || !e.pointer) return next(e)
220    const { column, row } = e.pointer
221    await update($, wheelAtom, (w) => ({ n: w.n + 1, by: e.by, column, row }))
222    return {}
223  }).catch(() => undefined)
224
225  // Les demandes de la région, une à la fois, dans l'ordre de leurs numéros.
226  on('ui.message', async ($, e) => {
227    const post = asPost(e.data)
228    if (!post) return {}
229    const job = chain.then(() => handlePost($, post, e.surface))
230    chain = job.catch(() => undefined)
231    await job.catch(() => undefined)
232    return {}
233  })
234}
235
236// ─── Vue de la région ───────────────────────────────────────────────────────
237
238async function buildView($: any, columns: number, lines: number, isFullscreen: boolean): Promise<View> {
239  const root = await rootOf($)
240  const p = await read($, platformAtom)
241  const sep = p.isReady ? p.sep : guessSep(root)
242  const os = p.isReady ? p.os : guessOs(root)
243  const options = await read($, optionsAtom)
244  const expanded = await read($, expandedAtom)
245  const listings = await read($, listingsAtom)
246  const tabs = await read($, tabsAtom)
247  const active = await read($, activeAtom)
248  const docs = await read($, docsAtom)
249  const flat = flattenRows(listings, new Set(expanded))
250  const doc = active ? docs[active] : undefined
251  const view: View = {
252    root,
253    rootName: folderName(root),
254    sep,
255    os,
256    options,
257    rows: flat.rows,
258    rowsHidden: flat.hidden,
259    find: await read($, findAtom),
260    tabs,
261    active,
262    dirty: tabs.filter((t) => docs[t.path]?.draft != null).map((t) => t.path),
263    touched: await touchedRel($, root, os),
264    doc: doc ? docView(doc) : null,
265    ack: await read($, seenAtom),
266    wheel: await read($, wheelAtom),
267    reveal: await read($, revealAtom),
268    apps: p.apps,
269    isMaximized: await read($, maximizedAtom),
270    isFullscreen,
271    columns,
272    lines,
273  }
274  // Les props de la région sont bornées (100 000 caractères) : l'arborescence cède la place.
275  while (view.rows.length > 50 && JSON.stringify(view).length > 95_000) {
276    const keep = Math.floor(view.rows.length * 0.7)
277    view.rowsHidden += view.rows.length - keep
278    view.rows = view.rows.slice(0, keep)
279  }
280  return view
281}
282
283function docView(d: Doc): DocView {
284  return {
285    path: d.path,
286    state: d.state,
287    error: d.error,
288    text: d.state === 'ready' ? (d.draft ?? d.text) : '',
289    isDirty: d.draft != null,
290    rev: d.rev,
291    origin: d.origin,
292    changedOnDisk: d.changedOnDisk,
293    lineCount: d.lineCount,
294    window: d.window,
295    size: d.size,
296    eol: d.eol,
297  }
298}
299
300// ─── Demandes de la région ──────────────────────────────────────────────────
301
302function asPost(data: unknown): Post | null {
303  if (!data || typeof data !== 'object') return null
304  const { inst, ops } = data as { inst?: unknown; ops?: unknown }
305  if (typeof inst !== 'string' || !Array.isArray(ops)) return null
306  return { inst, ops: ops.filter((o) => o && typeof o === 'object' && typeof o.seq === 'number' && typeof o.k === 'string') }
307}
308
309async function handlePost($: any, post: Post, surface: string) {
310  let seen = await read($, seenAtom)
311  if (seen.inst !== post.inst) seen = { inst: post.inst, seq: 0 }
312  const ops = post.ops.filter((o) => o.seq > seen.seq).sort((a, b) => a.seq - b.seq)
313  for (const op of ops) {
314    try {
315      await applyOp($, op, post.inst, op.seq, surface)
316    } catch (err) {
317      await toast($, `Fichiers : ${short(err)}`)
318    }
319    seen = { inst: post.inst, seq: op.seq }
320    // Accusée au fil de l'eau : la région retire l'opération de sa file dès le redessin.
321    await update($, seenAtom, () => seen)
322  }
323  if ((await read($, seenAtom)).inst !== post.inst) await update($, seenAtom, () => seen)
324}
325
326async function applyOp($: any, op: Op, inst: string, seq: number, surface: string) {
327  switch (op.k) {
328    case 'toggle':
329      return toggleDir($, op.path)
330    case 'expand':
331      return expandDirs($, op.paths)
332    case 'open':
333      return openFile($, op.path, op.preview)
334    case 'activate':
335      if ((await read($, tabsAtom)).some((t) => t.path === op.path)) await update($, activeAtom, () => op.path)
336      return
337    case 'pin':
338      return pinTab($, op.path)
339    case 'close':
340      return closeTab($, op.path)
341    case 'save':
342      return saveDoc($, op.path, op.text, `save:${inst}:${seq}`)
343    case 'draft':
344      return setDraft($, op.path, op.text)
345    case 'reload':
346      return reloadDoc($, op.path, true)
347    case 'keep':
348      return patchDoc($, op.path, { changedOnDisk: false })
349    case 'window':
350      return moveWindow($, op.path, op.from)
351    case 'find':
352      return runFind($, op.query)
353    case 'option':
354      return setOption($, op.key, op.value)
355    case 'act':
356      return act($, op.id, op.path, op.isDir, surface)
357    case 'pane':
358      return op.what === 'close' ? quietly(() => $.ui.close({ id: PANE })) : toggleMaximized($)
359    case 'refresh':
360      fileIndex = null
361      return refreshAll($)
362  }
363}
364
365// ─── Racine, plateforme, options ────────────────────────────────────────────
366
367const guessSep = (root: string): '\\' | '/' => (/^[A-Za-z]:|\\/.test(root) ? '\\' : '/')
368const guessOs = (root: string): Os => (guessSep(root) === '\\' ? 'windows' : 'linux')
369
370async function rootOf($: any): Promise<string> {
371  return (await read($, rootAtom)) ?? (await $.session.cwd())
372}
373
374/** Le séparateur et le système, sans attendre la recherche des applications. */
375async function quickPlatform($: any): Promise<{ os: Os; sep: '\\' | '/' }> {
376  const p = await read($, platformAtom)
377  if (p.isReady) return p
378  const cwd: string = await $.session.cwd()
379  return { os: guessOs(cwd), sep: guessSep(cwd) }
380}
381
382async function run($: any, argv: string[], timeoutMs = 15_000, cwd?: string): Promise<Run> {
383  try {
384    return await $.process.run(argv, { timeoutMs, ...(cwd ? { cwd } : {}) })
385  } catch (err) {
386    return { exitCode: -1, stdout: '', stderr: short(err) }
387  }
388}
389
390/** Le système et les applications de « Ouvrir dans », cherchées une fois par session. */
391async function platform($: any): Promise<Platform> {
392  const known = await read($, platformAtom)
393  if (known.isReady) return known
394  const { sep } = await quickPlatform($)
395  const osEnv = await quietly(() => $.env.get('OS'))
396  let os: Os = osEnv === 'Windows_NT' || sep === '\\' ? 'windows' : 'linux'
397  if (os !== 'windows' && /darwin/i.test((await run($, ['uname', '-s'], 5000)).stdout)) os = 'mac'
398  const has = async (cmd: string) => (await run($, [os === 'windows' ? 'where' : 'which', cmd], 5000)).exitCode === 0
399  const apps: App[] = [{ id: 'default', label: 'Application par défaut' }]
400  if (await has('code')) apps.push({ id: 'code', label: 'Visual Studio Code' })
401  if (await has('cursor')) apps.push({ id: 'cursor', label: 'Cursor' })
402  if (os === 'windows') apps.push({ id: 'notepad', label: 'Bloc-notes' })
403  if (os === 'mac') apps.push({ id: 'textedit', label: 'TextEdit' })
404  const next: Platform = { os, sep, apps, isReady: true }
405  await update($, platformAtom, () => next)
406  return next
407}
408
409/** Les options du menu ⋮, gardées d'une session à l'autre. */
410async function loadOptions($: any) {
411  if (optionsLoaded) return
412  optionsLoaded = true
413  const stored = await quietly(() => $.store.get('options'))
414  if (stored !== undefined) await update($, optionsAtom, () => normalizeOptions(stored))
415}
416
417async function setOption($: any, key: OptionKey, value: boolean | number) {
418  const next: Options = normalizeOptions({ ...(await read($, optionsAtom)), [key]: value })
419  await update($, optionsAtom, () => next)
420  await quietly(() => $.store.set('options', next))
421}
422
423async function setRoot($: any, abs: string) {
424  const old = await rootOf($)
425  if (old === abs) return
426  const { os, sep } = await quickPlatform($)
427  // Les onglets suivent la nouvelle racine quand leur fichier est dessous ; les autres se ferment.
428  const tabs = await read($, tabsAtom)
429  const docs = await read($, docsAtom)
430  const moved: Tab[] = []
431  const movedDocs: Record<string, Doc> = {}
432  let lost = 0
433  for (const t of tabs) {
434    const rel = relFrom(abs, toAbs(old, t.path, sep), os)
435    if (rel === null || rel === '') {
436      if (docs[t.path]?.draft != null) lost += 1
437      continue
438    }
439    moved.push({ ...t, path: rel })
440    const d = docs[t.path]
441    if (d) movedDocs[rel] = { ...d, path: rel }
442  }
443  const active = await read($, activeAtom)
444  const activeRel = active ? relFrom(abs, toAbs(old, active, sep), os) : null
445  await update($, rootAtom, () => abs)
446  await update($, expandedAtom, () => [])
447  await update($, listingsAtom, () => ({}))
448  await update($, findAtom, () => EMPTY_FIND)
449  await update($, tabsAtom, () => moved)
450  await update($, docsAtom, () => movedDocs)
451  await update($, activeAtom, () => (activeRel && moved.some((t) => t.path === activeRel) ? activeRel : (moved[0]?.path ?? null)))
452  bigDocs.clear()
453  fileIndex = null
454  if (lost) await toast($, `${lost} brouillon(s) hors du nouveau dossier abandonné(s).`)
455}
456
457// ─── Arborescence ───────────────────────────────────────────────────────────
458
459async function loadListing($: any, root: string, rel: string, sep: string): Promise<Listing> {
460  try {
461    const raw: { name: string; kind: string; isLink: boolean }[] = await $.fs.list(toAbs(root, rel, sep))
462    const entries: Entry[] = []
463    for (const e of raw) {
464      let kind: Entry['kind'] = e.kind === 'dir' ? 'dir' : 'file'
465      if (e.kind === 'other' && e.isLink) {
466        const st = await quietly(() => $.fs.stat(toAbs(root, joinRel(rel, e.name), sep)))
467        if (st?.kind === 'dir') kind = 'dir'
468      }
469      entries.push({ name: e.name, kind })
470    }
471    entries.sort((a, b) => compareNames(a.name, b.name))
472    return { entries: entries.slice(0, MAX_ENTRIES), hidden: Math.max(0, entries.length - MAX_ENTRIES), error: null }
473  } catch (err) {
474    return { entries: [], hidden: 0, error: `Lecture impossible : ${short(err)}` }
475  }
476}
477
478/** Lit les dossiers affichés qui ne le sont pas encore (la racine et les dossiers dépliés). */
479async function ensureListings($: any) {
480  const root = await rootOf($)
481  const { sep } = await quickPlatform($)
482  const listings = await read($, listingsAtom)
483  const missing = ['', ...(await read($, expandedAtom))].filter((d) => !listings[d])
484  if (!missing.length) return
485  const loaded = await Promise.all(missing.map((d) => loadListing($, root, d, sep)))
486  await update($, listingsAtom, (l) => {
487    const next = { ...l }
488    missing.forEach((d, i) => {
489      next[d] = loaded[i]!
490    })
491    return next
492  })
493}
494
495async function toggleDir($: any, path: string) {
496  const expanded = await read($, expandedAtom)
497  if (expanded.includes(path)) await update($, expandedAtom, (x) => x.filter((p) => p !== path))
498  else await update($, expandedAtom, (x) => [...x, path])
499  await ensureListings($)
500}
501
502async function expandDirs($: any, paths: string[]) {
503  await update($, expandedAtom, (x) => [...new Set([...x, ...paths.filter(Boolean)])])
504  await ensureListings($)
505}
506
507/** Montre `path` dans l'arborescence : ses dossiers dépliés, la région le sélectionne. */
508async function revealInTree($: any, path: string) {
509  await expandDirs($, ancestors(path))
510  const options = await read($, optionsAtom)
511  if (!options.showTree) await setOption($, 'showTree', true)
512  await update($, revealAtom, (r) => ({ path, n: r.n + 1 }))
513}
514
515// ─── Documents ──────────────────────────────────────────────────────────────
516
517const emptyDoc = (path: string): Doc => ({
518  path,
519  state: 'loading',
520  error: null,
521  text: '',
522  draft: null,
523  eol: '\n',
524  rev: 0,
525  origin: 'disk',
526  changedOnDisk: false,
527  lineCount: 0,
528  window: null,
529  size: 0,
530  mtimeMs: 0,
531})
532
533/** Une fenêtre de lignes d'un fichier volumineux, bornée en lignes et en caractères. */
534function windowOf(lines: readonly string[], from: number) {
535  const start = clamp(from, 0, Math.max(0, lines.length - 1))
536  const out: string[] = []
537  let size = 0
538  for (let i = start; i < lines.length && out.length < WINDOW_LINES && size < WINDOW_CHARS; i++) {
539    const line = lines[i] ?? ''
540    const shown = line.length > 2000 ? `${line.slice(0, 2000)}…` : line
541    out.push(shown)
542    size += shown.length + 1
543  }
544  return { from: start, text: out.join('\n') }
545}
546
547/** Le fichier tel qu'il est sur le disque, à partir du document connu (son brouillon gardé). */
548async function readDoc($: any, path: string, prev: Doc | undefined): Promise<Doc> {
549  const root = await rootOf($)
550  const { sep } = await quickPlatform($)
551  const abs = toAbs(root, path, sep)
552  const base = prev ?? emptyDoc(path)
553  const fail = (error: string, size = 0): Doc => ({ ...base, state: 'error', error, text: '', window: null, rev: base.rev + 1, origin: 'disk', size })
554  const stat = await quietly(() => $.fs.stat(abs))
555  if (!stat) return fail('Fichier introuvable : il a été supprimé ou déplacé.')
556  if (stat.kind === 'dir') return fail("C'est un dossier.")
557  if (stat.size > MAX_READ) return fail('Fichier trop volumineux (plus de 4 Mio) : ouvrez-le dans une autre application.', stat.size)
558  let raw: string
559  try {
560    raw = await $.fs.read(abs)
561  } catch (err) {
562    return fail(`Lecture impossible : ${short(err)}`, stat.size)
563  }
564  const common = { error: null, rev: base.rev + 1, origin: 'disk', size: stat.size, mtimeMs: stat.mtimeMs, changedOnDisk: false }
565  if (raw.slice(0, 8000).includes('\u0000')) {
566    bigDocs.delete(path)
567    return { ...base, ...common, state: 'binary', text: '', window: null, lineCount: 0 }
568  }
569  const eol: Doc['eol'] = raw.includes('\r\n') ? '\r\n' : '\n'
570  const text = raw.replace(/\r\n/g, '\n')
571  const lines = text.split('\n')
572  if (JSON.stringify(text).length > EDIT_LIMIT) {
573    bigDocs.set(path, lines)
574    return { ...base, ...common, state: 'large', text: '', draft: null, eol, lineCount: lines.length, window: windowOf(lines, base.window?.from ?? 0) }
575  }
576  bigDocs.delete(path)
577  return { ...base, ...common, state: 'ready', text, eol, lineCount: lines.length, window: null }
578}
579
580async function setDoc($: any, doc: Doc) {
581  await update($, docsAtom, (d) => ({ ...d, [doc.path]: doc }))
582}
583
584async function patchDoc($: any, path: string, patch: Partial<Doc>) {
585  const doc = (await read($, docsAtom))[path]
586  if (doc) await setDoc($, { ...doc, ...patch })
587}
588
589async function openFile($: any, path: string, preview: boolean) {
590  const options = await read($, optionsAtom)
591  const isPreview = preview && options.previewTabs
592  const tabs = await read($, tabsAtom)
593  const docs = await read($, docsAtom)
594  let next: Tab[]
595  if (tabs.some((t) => t.path === path)) {
596    // Ouvert pour de bon (double clic, Entrée) : l'onglet d'aperçu est épinglé.
597    next = tabs.map((t) => (t.path === path && t.isPreview && !isPreview ? { ...t, isPreview: false } : t))
598  } else {
599    const tab: Tab = { path, isPreview }
600    // Un nouvel aperçu prend la place de l'ancien, s'il n'a pas de modifications.
601    const old = isPreview ? tabs.findIndex((t) => t.isPreview && docs[t.path]?.draft == null) : -1
602    next = [...tabs]
603    if (old >= 0) next[old] = tab
604    else {
605      // Le nouvel onglet se place juste après l'onglet actif.
606      const current = await read($, activeAtom)
607      const at = tabs.findIndex((t) => t.path === current)
608      next.splice(at < 0 ? tabs.length : at + 1, 0, tab)
609    }
610  }
611  const keep = new Set(next.map((t) => t.path))
612  await update($, tabsAtom, () => next)
613  await update($, activeAtom, () => path)
614  await update($, docsAtom, (d) => Object.fromEntries(Object.entries(d).filter(([p]) => keep.has(p))))
615  for (const p of bigDocs.keys()) if (!keep.has(p)) bigDocs.delete(p)
616  const doc = (await read($, docsAtom))[path]
617  if (!doc || doc.state === 'error') {
618    await setDoc($, emptyDoc(path))
619    await setDoc($, await readDoc($, path, doc))
620  }
621}
622
623async function pinTab($: any, path: string) {
624  const tabs = await read($, tabsAtom)
625  if (tabs.some((t) => t.path === path && t.isPreview)) await update($, tabsAtom, (x) => x.map((t) => (t.path === path ? { ...t, isPreview: false } : t)))
626}
627
628async function closeTab($: any, path: string) {
629  const tabs = await read($, tabsAtom)
630  const i = tabs.findIndex((t) => t.path === path)
631  if (i < 0) return
632  const next = tabs.filter((t) => t.path !== path)
633  await update($, tabsAtom, () => next)
634  if ((await read($, activeAtom)) === path) await update($, activeAtom, () => next[Math.min(i, next.length - 1)]?.path ?? null)
635  await update($, docsAtom, (d) => Object.fromEntries(Object.entries(d).filter(([p]) => p !== path)))
636  bigDocs.delete(path)
637}
638
639async function saveDoc($: any, path: string, text: string, origin: string) {
640  const doc = (await read($, docsAtom))[path]
641  const root = await rootOf($)
642  const { sep } = await quickPlatform($)
643  const abs = toAbs(root, path, sep)
644  const eol = doc?.eol ?? '\n'
645  try {
646    await $.fs.write(abs, eol === '\r\n' ? text.replace(/\n/g, '\r\n') : text)
647  } catch (err) {
648    // Rien n'est perdu : le texte reste en brouillon.
649    if (doc) await setDoc($, { ...doc, draft: text })
650    await toast($, `Enregistrement impossible : ${short(err)}`)
651    return
652  }
653  const stat = await quietly(() => $.fs.stat(abs))
654  const base = doc ?? emptyDoc(path)
655  await setDoc($, {
656    ...base,
657    state: 'ready',
658    error: null,
659    text,
660    draft: null,
661    rev: base.rev + 1,
662    origin,
663    changedOnDisk: false,
664    lineCount: text.split('\n').length,
665    mtimeMs: stat?.mtimeMs ?? base.mtimeMs,
666    size: stat?.size ?? text.length,
667  })
668  await pinTab($, path)
669}
670
671async function setDraft($: any, path: string, text: string | null) {
672  await patchDoc($, path, { draft: text })
673  // Modifier un aperçu le garde ouvert, comme dans l'app desktop.
674  if (text !== null) await pinTab($, path)
675}
676
677/**
678 * Relit un document ; `force` abandonne le brouillon (Recharger le fichier). `byClaude` : Claude
679 * vient de modifier le fichier, la région le dit (« Rechargé : modifié par Claude »).
680 */
681async function reloadDoc($: any, path: string, force: boolean, byClaude = false) {
682  const doc = (await read($, docsAtom))[path]
683  if (!doc) return
684  const fresh = await readDoc($, path, doc)
685  if (force || doc.draft == null) {
686    const origin = byClaude && !force && fresh.state !== 'error' ? 'claude' : fresh.origin
687    return setDoc($, { ...fresh, origin, draft: null, changedOnDisk: false })
688  }
689  if (fresh.state === 'ready' && fresh.text === doc.text) return setDoc($, { ...doc, mtimeMs: fresh.mtimeMs, size: fresh.size })
690  // Un brouillon est en cours : le disque est retenu, la région propose de recharger.
691  await setDoc($, { ...fresh, draft: doc.draft, rev: doc.rev, origin: doc.origin, changedOnDisk: true })
692}
693
694async function moveWindow($: any, path: string, from: number) {
695  const doc = (await read($, docsAtom))[path]
696  if (!doc || doc.state !== 'large') return
697  let lines = bigDocs.get(path)
698  if (!lines) {
699    // Cache perdu (rechargement du module) : on relit le fichier.
700    const fresh = await readDoc($, path, doc)
701    await setDoc($, fresh)
702    lines = bigDocs.get(path)
703    if (!lines) return
704  }
705  await setDoc($, { ...((await read($, docsAtom))[path] ?? doc), window: windowOf(lines, from) })
706}
707
708// ─── Recherche de fichiers ──────────────────────────────────────────────────
709
710async function runFind($: any, query: string) {
711  if (!query.trim()) return update($, findAtom, () => EMPTY_FIND)
712  await update($, findAtom, (f) => ({ ...f, query, isLoading: true }))
713  const { results, total } = searchPaths(await indexOf($), query.trim())
714  await update($, findAtom, () => ({ query, results, total, isLoading: false }))
715}
716
717/** Les chemins du dossier : `git ls-files` dans un dépôt, sinon un parcours du disque. */
718async function indexOf($: any): Promise<string[]> {
719  const root = await rootOf($)
720  if (fileIndex && fileIndex.root === root) return fileIndex.paths
721  const git = await run($, ['git', '-c', 'core.quotepath=off', 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], 30_000, root)
722  const files = git.exitCode === 0 ? git.stdout.split('\0').filter(Boolean).slice(0, MAX_INDEX) : await walk($, root)
723  const dirs = new Set<string>()
724  for (const f of files) for (const a of ancestors(f)) dirs.add(a)
725  const paths = [...files, ...[...dirs].map((d) => `${d}/`)]
726  fileIndex = { root, paths }
727  return paths
728}
729
730async function walk($: any, root: string): Promise<string[]> {
731  const { sep } = await quickPlatform($)
732  const out: string[] = []
733  const queue = ['']
734  let visited = 0
735  while (queue.length && out.length < MAX_INDEX && visited < 3000) {
736    const dir = queue.shift() ?? ''
737    visited += 1
738    const entries: { name: string; kind: string }[] = (await quietly(() => $.fs.list(toAbs(root, dir, sep)))) ?? []
739    for (const e of entries) {
740      const p = joinRel(dir, e.name)
741      if (e.kind === 'dir') {
742        if (!SKIP_DIRS.has(e.name)) queue.push(p)
743      } else out.push(p)
744    }
745  }
746  return out
747}
748
749// ─── Actions des menus ──────────────────────────────────────────────────────
750
751async function act($: any, id: string, path: string, isDir: boolean, surface: string) {
752  const root = await rootOf($)
753  const { os, sep } = await quickPlatform($)
754  const abs = toAbs(root, path, sep)
755  if (id === 'attach') {
756    const ref = mention(path, isDir)
757    const filled = await quietly(() => $.prompt.fill({ text: `${ref} `, mode: 'insert' }))
758    return toast($, filled?.isFilled ? `Joint au prompt : ${ref}` : "Le prompt n'accepte pas de texte pour l'instant (une boîte de dialogue est ouverte ?).")
759  }
760  if (id === 'copy:abs') return copy($, abs, surface)
761  if (id === 'copy:rel') return copy($, path || '.', surface)
762  if (id === 'copy:name') return copy($, baseName(path) || folderName(root), surface)
763  if (id === 'copyContent') {
764    const doc = (await read($, docsAtom))[path]
765    const text = doc?.state === 'ready' ? (doc.draft ?? doc.text) : await quietly(() => $.fs.read(abs))
766    if (typeof text !== 'string') return toast($, 'Lecture du fichier impossible.')
767    return copy($, text, surface, `contenu de ${baseName(path)} (${text.split('\n').length} lignes)`)
768  }
769  if (id === 'revealInTree') return revealInTree($, path)
770  if (id === 'reveal') return launch($, revealArgv(os, abs, isDir), explorerName(os))
771  if (id.startsWith('openIn:')) {
772    const app = id.slice('openIn:'.length)
773    const argv = openArgv(os, app, abs)
774    const label = (await read($, platformAtom)).apps.find((a) => a.id === app)?.label ?? app
775    return argv ? launch($, argv, label) : toast($, `Application inconnue : ${app}`)
776  }
777}
778
779async function copy($: any, text: string, surface: string, what?: string) {
780  const done = await quietly(() => $.ui.copy({ text, surface }))
781  await toast($, done?.isCopied ? `Copié : ${what ?? text}` : `Copie impossible${done?.reason ? ` (${done.reason})` : ''}.`)
782}
783
784const explorerName = (os: Os) => (os === 'windows' ? "l'Explorateur" : os === 'mac' ? 'le Finder' : 'le gestionnaire de fichiers')
785
786function revealArgv(os: Os, abs: string, isDir: boolean): string[] {
787  if (os === 'windows') return ['explorer.exe', `/select,${abs}`]
788  if (os === 'mac') return ['open', '-R', abs]
789  return ['xdg-open', isDir ? abs : abs.slice(0, abs.lastIndexOf('/')) || '/']
790}
791
792function openArgv(os: Os, app: string, abs: string): string[] | null {
793  if (os === 'windows') {
794    if (app === 'default') return ['explorer.exe', abs]
795    if (app === 'code' || app === 'cursor') return ['cmd', '/c', app, abs]
796    // `start` rend la main tout de suite : le Bloc-notes, lui, attendrait sa fermeture.
797    if (app === 'notepad') return ['cmd', '/c', 'start', 'notepad.exe', abs]
798    return null
799  }
800  if (os === 'mac') {
801    if (app === 'default') return ['open', abs]
802    if (app === 'code') return ['open', '-a', 'Visual Studio Code', abs]
803    if (app === 'cursor') return ['open', '-a', 'Cursor', abs]
804    if (app === 'textedit') return ['open', '-a', 'TextEdit', abs]
805    return null
806  }
807  if (app === 'default') return ['xdg-open', abs]
808  if (app === 'code' || app === 'cursor') return [app, abs]
809  return null
810}
811
812/** Lance une application hors de la file des demandes : elle peut mettre du temps à rendre la main. */
813function launch($: any, argv: string[], label: string) {
814  $.clock.after(0, async () => {
815    const res = await run($, argv, 20_000)
816    // L'Explorateur rend un code d'erreur même quand il a ouvert la fenêtre.
817    const isExplorer = /explorer\.exe$/i.test(argv[0] ?? '')
818    if (res.exitCode !== 0 && !isExplorer) await toast($, `Ouverture dans ${label} impossible : ${res.stderr.split('\n')[0] || `code ${res.exitCode}`}`)
819  })
820  return toast($, `Ouverture dans ${label}…`)
821}
822
823// ─── Panneau ────────────────────────────────────────────────────────────────
824
825function paneSize(isMaximized: boolean) {
826  if (isMaximized) return { rows: Math.max(20, viewport.rows - 4), columns: Math.max(80, viewport.columns - 16) }
827  return { rows: 32, columns: clamp(Math.round(viewport.columns * 0.62), 90, 150) }
828}
829
830async function toggleMaximized($: any) {
831  const next = !(await read($, maximizedAtom))
832  await update($, maximizedAtom, () => next)
833  await quietly(() => $.ui.open({ id: PANE, title: TITLE, ...paneSize(next) }))
834}
835
836// ─── Fraîcheur ──────────────────────────────────────────────────────────────
837
838// Après un outil de Claude : une relecture groupée, 400 ms après le dernier.
839function schedule($: any) {
840  refreshTimer?.cancel()
841  refreshTimer = $.clock.after(400, () => {
842    refreshTimer = null
843    fileIndex = null
844    void refreshAll($)
845  })
846}
847
848async function watch($: any) {
849  const panes: readonly { id: string }[] = (await quietly(() => $.ui.panes())) ?? []
850  if (panes.some((p) => p.id === PANE)) await refreshAll($)
851}
852
853async function refreshAll($: any) {
854  await refreshListings($)
855  await refreshDocs($)
856}
857
858async function refreshListings($: any) {
859  const listings = await read($, listingsAtom)
860  const dirs = Object.keys(listings)
861  if (!dirs.length) return
862  const root = await rootOf($)
863  const { sep } = await quickPlatform($)
864  const fresh = await Promise.all(dirs.map((d) => loadListing($, root, d, sep)))
865  const changed = dirs.filter((d, i) => JSON.stringify(listings[d]) !== JSON.stringify(fresh[i]))
866  if (!changed.length) return
867  await update($, listingsAtom, (l) => {
868    const next = { ...l }
869    dirs.forEach((d, i) => {
870      if (changed.includes(d)) next[d] = fresh[i]!
871    })
872    return next
873  })
874}
875
876async function refreshDocs($: any) {
877  const docs = await read($, docsAtom)
878  const root = await rootOf($)
879  const { os, sep } = await quickPlatform($)
880  const touched = new Set(await touchedRel($, root, os))
881  for (const doc of Object.values(docs)) {
882    if (doc.state === 'loading') continue
883    const stat = await quietly(() => $.fs.stat(toAbs(root, doc.path, sep)))
884    if (stat && stat.mtimeMs === doc.mtimeMs && stat.size === doc.size && doc.state !== 'error') continue
885    if (!stat && doc.state === 'error') continue
886    await reloadDoc($, doc.path, false, touched.has(doc.path))
887  }
888}
889
890// ─── Fichiers modifiés par Claude ───────────────────────────────────────────
891
892/** Marque le fichier nommé par un appel d'outil d'écriture (`file_path`, `notebook_path`). */
893async function markTouched($: any, input: Record<string, unknown>) {
894  const raw = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : ''
895  if (!raw.trim()) return
896  const { sep } = await quickPlatform($)
897  const abs = resolveFrom(await $.session.cwd(), raw, sep)
898  await update($, touchedAtom, (t) => [...t.filter((p) => p !== abs), abs].slice(-MAX_TOUCHED))
899}
900
901async function clearTouched($: any) {
902  if ((await read($, touchedAtom)).length) await update($, touchedAtom, () => [])
903  // La note « Rechargé : modifié par Claude » s'efface avec les marques.
904  const docs = await read($, docsAtom)
905  if (Object.values(docs).some((d) => d.origin === 'claude')) {
906    await update($, docsAtom, (d) => Object.fromEntries(Object.entries(d).map(([k, v]) => [k, v.origin === 'claude' ? { ...v, origin: 'disk' } : v])))
907  }
908}
909
910/** Les fichiers marqués qui sont sous la racine, relatifs à elle. */
911async function touchedRel($: any, root: string, os: Os): Promise<string[]> {
912  const out: string[] = []
913  for (const abs of await read($, touchedAtom)) {
914    const rel = relFrom(root, abs, os)
915    if (rel) out.push(rel)
916  }
917  return out
918}
919
hooks/model.ts 1169 lines
1// Données pures du navigateur de fichiers, partagées par le module des hooks (register.tsx)
2// et la région interactive (browser.tsx) : options, chemins, tri, arborescence, recherche
3// floue, colonnes d'affichage et retour à la ligne, coloration syntaxique, édition, menus.
4// Rien ici ne parle au moteur.
5
6import type { App, Listing, OptionKey, Options, Os } from '../types'
7
8// ─── Options et plafonds ────────────────────────────────────────────────────
9
10/** Les options par défaut : celles cochées dans le menu ⋮ de l'app desktop. */
11export const DEFAULT_OPTIONS: Options = {
12  showTree: true,
13  previewTabs: true,
14  autoSave: true,
15  tabIndents: true,
16  wordWrap: true,
17  indentGuides: true,
18  showWhitespace: false,
19  scrollBeyondLastLine: false,
20  tabSize: 4,
21  sidebarWidth: 0,
22}
23
24export const TAB_SIZES = [2, 4, 8]
25
26/** Des options lues du stockage : chaque champ inconnu ou mal typé reprend sa valeur par défaut. */
27export function normalizeOptions(raw: unknown): Options {
28  const o: Options = { ...DEFAULT_OPTIONS }
29  if (raw && typeof raw === 'object') {
30    for (const key of Object.keys(DEFAULT_OPTIONS) as OptionKey[]) {
31      const value = (raw as Record<string, unknown>)[key]
32      if (typeof value === typeof o[key]) (o as Record<string, unknown>)[key] = value
33    }
34  }
35  if (!TAB_SIZES.includes(o.tabSize)) o.tabSize = 4
36  o.sidebarWidth = clamp(Math.round(o.sidebarWidth), 0, 200)
37  return o
38}
39
40// Les données de la région passent par ses props, bornées à 100 000 caractères sérialisés :
41// au-delà de EDIT_LIMIT, un fichier est lu par fenêtres de lignes, en lecture seule.
42export const EDIT_LIMIT = 60_000
43export const WINDOW_CHARS = 30_000
44export const WINDOW_LINES = 600
45/** Lignes de l'arborescence envoyées à la région, au plus. */
46export const MAX_ROWS = 1200
47/** Entrées gardées par dossier. */
48export const MAX_ENTRIES = 2000
49/** Résultats de recherche envoyés. */
50export const MAX_RESULTS = 150
51/** Chemins indexés pour la recherche de fichiers. */
52export const MAX_INDEX = 30_000
53/** Ce que `$.fs.read` accepte. */
54export const MAX_READ = 4 * 1024 * 1024
55
56export const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))
57
58// ─── Chemins ────────────────────────────────────────────────────────────────
59// Dans le mod, un chemin relatif à la racine est séparé par `/` ; '' est la racine.
60
61export const joinRel = (dir: string, name: string) => (dir ? `${dir}/${name}` : name)
62export const parentOf = (rel: string) => {
63  const i = rel.lastIndexOf('/')
64  return i < 0 ? '' : rel.slice(0, i)
65}
66export const baseName = (rel: string) => rel.slice(rel.lastIndexOf('/') + 1)
67
68/** Les dossiers qui contiennent `rel`, du plus haut au plus proche : `a/b/c.md` → `a`, `a/b`. */
69export function ancestors(rel: string): string[] {
70  const parts = rel.split('/').filter(Boolean)
71  return parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join('/'))
72}
73
74/**
75 * Les marques « modifié par Claude » de l'arborescence : les fichiers touchés, et leurs dossiers
76 * parents pour qu'on les trouve même repliés.
77 */
78export function touchedMarks(touched: readonly string[]): { files: Set<string>; dirs: Set<string> } {
79  const dirs = new Set<string>()
80  for (const f of touched) for (const a of ancestors(f)) dirs.add(a)
81  return { files: new Set(touched), dirs }
82}
83
84export const isAbsolutePath = (p: string) => /^([A-Za-z]:[\\/]|[\\/])/.test(p)
85
86/** Le chemin absolu d'un chemin relatif à la racine, avec le séparateur du système. */
87export function toAbs(root: string, rel: string, sep: string): string {
88  if (!rel) return root
89  const base = root.replace(/[\\/]+$/, '')
90  return `${base}${sep}${rel.split('/').join(sep)}`
91}
92
93/** Un chemin tapé (relatif à `base` ou absolu) rendu absolu, `.` et `..` résolus. */
94export function resolveFrom(base: string, input: string, sep: '\\' | '/'): string {
95  const p = input.trim().replace(/^["']|["']$/g, '')
96  const abs = isAbsolutePath(p) ? p : `${base.replace(/[\\/]+$/, '')}/${p}`
97  const drive = /^[A-Za-z]:/.exec(abs)?.[0] ?? ''
98  const parts: string[] = []
99  for (const s of abs.slice(drive.length).split(/[\\/]+/)) {
100    if (!s || s === '.') continue
101    if (s === '..') parts.pop()
102    else parts.push(s)
103  }
104  return `${drive}${sep}${parts.join(sep)}`
105}
106
107/** `abs` relatif à `root` (séparé par `/`), ou null quand il est en dehors. */
108export function relFrom(root: string, abs: string, os: Os): string | null {
109  const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
110  const r = norm(root)
111  const a = norm(abs)
112  const same = (x: string, y: string) => (os === 'linux' ? x === y : x.toLowerCase() === y.toLowerCase())
113  if (same(a, r)) return ''
114  if (a.length > r.length && same(a.slice(0, r.length + 1), `${r}/`)) return a.slice(r.length + 1)
115  return null
116}
117
118/** Le nom d'un dossier absolu, pour l'en-tête. */
119export const folderName = (abs: string) => abs.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || abs
120
121/** La mention `@chemin` que le prompt de Claude Code lit comme un fichier joint. */
122export const mention = (rel: string, isDir: boolean) => {
123  const p = isDir && rel ? `${rel}/` : rel || '.'
124  return /\s/.test(p) ? `@"${p}"` : `@${p}`
125}
126
127// ─── Tri et arborescence ────────────────────────────────────────────────────
128
129/**
130 * Ordre de l'app desktop : dossiers et fichiers mêlés, sans tenir compte de la casse, les
131 * nombres dans l'ordre naturel (`fichier2` avant `fichier10`).
132 */
133export function compareNames(a: string, b: string): number {
134  const parts = (s: string) => s.toLowerCase().match(/\d+|\D+/g) ?? []
135  const x = parts(a)
136  const y = parts(b)
137  for (let i = 0; i < Math.min(x.length, y.length); i++) {
138    const p = x[i]!
139    const q = y[i]!
140    if (p === q) continue
141    if (/^\d/.test(p) && /^\d/.test(q)) {
142      const d = Number(p) - Number(q)
143      if (d !== 0) return d
144      continue
145    }
146    return p < q ? -1 : 1
147  }
148  if (x.length !== y.length) return x.length - y.length
149  return a < b ? -1 : a > b ? 1 : 0
150}
151
152/**
153 * Une ligne de l'arborescence, codée en une chaîne pour tenir dans les props : la profondeur
154 * en base 36, une lettre de sorte, le nom. `f` fichier, `d` dossier replié, `o` dossier
155 * déplié, `l` dossier en cours de lecture, `x` une note (erreur, dossier vide, entrées masquées).
156 */
157export type RowKind = 'f' | 'd' | 'o' | 'l' | 'x'
158export type TreeRow = { depth: number; kind: RowKind; name: string; path: string }
159
160export const encodeRow = (depth: number, kind: RowKind, name: string) => `${Math.min(depth, 35).toString(36)}${kind}${name}`
161
162/** Les lignes décodées, chacune avec son chemin relatif (celui du dossier pour une note). */
163export function decodeRows(rows: readonly string[]): TreeRow[] {
164  const stack: string[] = []
165  return rows.map((r) => {
166    const depth = parseInt(r[0] ?? '0', 36)
167    const kind = (r[1] ?? 'x') as RowKind
168    const name = r.slice(2)
169    stack.length = depth
170    const parent = stack.join('/')
171    const path = kind === 'l' || kind === 'x' ? parent : joinRel(parent, name)
172    if (kind === 'o') stack[depth] = name
173    return { depth, kind, name, path }
174  })
175}
176
177/** Les lignes visibles : chaque dossier déplié suivi de son contenu, plafonnées à `max`. */
178export function flattenRows(listings: Readonly<Record<string, Listing>>, expanded: ReadonlySet<string>, max = MAX_ROWS) {
179  const rows: string[] = []
180  let hidden = 0
181  const push = (depth: number, kind: RowKind, name: string) => {
182    if (rows.length < max) rows.push(encodeRow(depth, kind, name))
183    else hidden += 1
184  }
185  const walk = (dir: string, depth: number) => {
186    const l = listings[dir]
187    if (!l) return push(depth, 'l', 'Lecture…')
188    if (l.error) return push(depth, 'x', l.error)
189    if (l.entries.length === 0) return push(depth, 'x', 'Dossier vide')
190    for (const entry of l.entries) {
191      const path = joinRel(dir, entry.name)
192      if (entry.kind === 'dir') {
193        const isOpen = expanded.has(path)
194        push(depth, isOpen ? 'o' : 'd', entry.name)
195        if (isOpen) walk(path, depth + 1)
196      } else push(depth, 'f', entry.name)
197    }
198    if (l.hidden > 0) push(depth, 'x', `… ${l.hidden} autres entrées`)
199  }
200  walk('', 0)
201  return { rows, hidden }
202}
203
204// ─── Recherche floue ────────────────────────────────────────────────────────
205
206const BOUNDARY = /[\\/\-_. ]/
207
208function subsequence(query: string, text: string): number {
209  let from = 0
210  let prev = -2
211  let score = 0
212  for (const ch of query) {
213    const i = text.indexOf(ch, from)
214    if (i < 0) return -1
215    score += 1
216    if (i === prev + 1) score += 4
217    if (i === 0 || BOUNDARY.test(text[i - 1] ?? '')) score += 3
218    prev = i
219    from = i + 1
220  }
221  return score
222}
223
224/**
225 * La note d'un chemin pour une recherche : -1 quand les lettres n'y sont pas dans l'ordre ;
226 * plus haut quand elles tombent dans le nom, se suivent ou commencent des mots.
227 */
228export function fuzzyScore(rawQuery: string, path: string): number {
229  const query = rawQuery.toLowerCase().replace(/\s+/g, '')
230  if (!query) return 0
231  const lower = path.toLowerCase()
232  const whole = subsequence(query, lower)
233  if (whole < 0) return -1
234  const trimmed = lower.replace(/\/$/, '')
235  const base = trimmed.slice(trimmed.lastIndexOf('/') + 1)
236  let score = whole
237  const inBase = subsequence(query, base)
238  if (inBase >= 0) score += 30 + inBase
239  if (base === query) score += 100
240  else if (base.startsWith(query)) score += 40
241  if (lower.includes(query)) score += 25
242  return score - path.length * 0.05
243}
244
245/** Les meilleurs chemins pour `query`, et combien correspondaient en tout. */
246export function searchPaths(paths: readonly string[], query: string, limit = MAX_RESULTS) {
247  const hits: { path: string; score: number }[] = []
248  for (const path of paths) {
249    const score = fuzzyScore(query, path)
250    if (score >= 0) hits.push({ path, score })
251  }
252  hits.sort((a, b) => b.score - a.score || a.path.length - b.path.length || compareNames(a.path, b.path))
253  return { results: hits.slice(0, limit).map((h) => h.path), total: hits.length }
254}
255
256// ─── Colonnes d'affichage ───────────────────────────────────────────────────
257
258const WIDE: readonly (readonly [number, number])[] = [
259  [0x1100, 0x115f],
260  [0x2e80, 0x303e],
261  [0x3041, 0x33ff],
262  [0x3400, 0x4dbf],
263  [0x4e00, 0x9fff],
264  [0xa000, 0xa4cf],
265  [0xac00, 0xd7a3],
266  [0xf900, 0xfaff],
267  [0xfe30, 0xfe4f],
268  [0xff00, 0xff60],
269  [0xffe0, 0xffe6],
270  [0x1f300, 0x1f64f],
271  [0x1f680, 0x1f6ff],
272  [0x1f900, 0x1f9ff],
273  [0x20000, 0x3fffd],
274]
275
276/** Colonnes qu'occupe un caractère : 0 pour une marque combinante, 2 pour un caractère large. */
277export function charWidth(cp: number): number {
278  if ((cp >= 0x300 && cp <= 0x36f) || (cp >= 0x200b && cp <= 0x200f) || (cp >= 0xfe00 && cp <= 0xfe0f)) return 0
279  for (const [lo, hi] of WIDE) if (cp >= lo && cp <= hi) return 2
280  return 1
281}
282
283export function strWidth(s: string): number {
284  let w = 0
285  for (const ch of s) w += charWidth(ch.codePointAt(0) ?? 32)
286  return w
287}
288
289/** `s` coupé à `width` colonnes, avec « … » s'il a fallu couper. */
290export function fit(s: string, width: number): string {
291  if (width <= 0) return ''
292  if (strWidth(s) <= width) return s
293  let out = ''
294  let w = 0
295  for (const ch of s) {
296    const cw = charWidth(ch.codePointAt(0) ?? 32)
297    if (w + cw > width - 1) break
298    out += ch
299    w += cw
300  }
301  return `${out}…`
302}
303
304/** `s` coupé ou complété d'espaces à exactement `width` colonnes. */
305export const pad = (s: string, width: number) => {
306  const t = fit(s, width)
307  return t + ' '.repeat(Math.max(0, width - strWidth(t)))
308}
309
310/**
311 * Un caractère d'une ligne tel qu'affiché : `i` son indice dans la ligne, `n` ses unités
312 * de code, `col` sa colonne, `w` sa largeur (une tabulation va au prochain taquet).
313 */
314export type Glyph = { i: number; n: number; col: number; w: number; ch: string }
315
316export function glyphs(line: string, tabSize: number): Glyph[] {
317  const out: Glyph[] = []
318  let col = 0
319  let i = 0
320  while (i < line.length) {
321    const cp = line.codePointAt(i) ?? 32
322    const n = cp > 0xffff ? 2 : 1
323    let ch = String.fromCodePoint(cp)
324    let w = charWidth(cp)
325    if (cp === 9) {
326      ch = '\t'
327      w = tabSize - (col % tabSize)
328    } else if (cp < 32 || (cp >= 0x7f && cp < 0xa0)) {
329      // Un caractère de contrôle ne s'affiche pas tel quel : un losange le signale.
330      ch = '�'
331      w = 1
332    }
333    out.push({ i, n, col, w, ch })
334    col += w
335    i += n
336  }
337  return out
338}
339
340export const glyphsWidth = (gl: readonly Glyph[]) => {
341  const last = gl[gl.length - 1]
342  return last ? last.col + last.w : 0
343}
344
345/** La colonne d'affichage de l'indice `ci` dans la ligne. */
346export function colOf(gl: readonly Glyph[], ci: number): number {
347  for (const g of gl) if (g.i >= ci) return g.col
348  return glyphsWidth(gl)
349}
350
351/** L'indice dans la ligne du caractère affiché à la colonne `col` (ou la fin de ligne). */
352export function indexAt(gl: readonly Glyph[], col: number, lineLength: number): number {
353  for (const g of gl) if (col < g.col + Math.max(1, g.w)) return col >= g.col + g.w / 2 && g.w > 1 ? g.i + g.n : g.i
354  return lineLength
355}
356
357/**
358 * Où commence chaque rangée d'une ligne repliée à `width` colonnes (indices de glyphes) :
359 * de préférence après un espace, sinon au milieu d'un mot.
360 */
361export function wrapRows(gl: readonly Glyph[], width: number): number[] {
362  const w = Math.max(2, width)
363  const starts = [0]
364  let rowStart = 0
365  let rowCol = 0
366  let lastBreak = -1
367  for (let g = 0; g < gl.length; g++) {
368    const x = gl[g]!
369    if (x.col + x.w - rowCol > w && g > rowStart) {
370      const s = lastBreak >= rowStart && lastBreak < g ? lastBreak + 1 : g
371      starts.push(s)
372      rowStart = s
373      rowCol = gl[s]!.col
374      lastBreak = -1
375    }
376    if (x.ch === ' ' || x.ch === '\t') lastBreak = g
377  }
378  return starts
379}
380
381// ─── Coloration syntaxique ──────────────────────────────────────────────────
382
383export type Lang = 'js' | 'c' | 'py' | 'sh' | 'ps' | 'json' | 'yaml' | 'css' | 'html' | 'md' | 'sql' | 'text'
384
385const EXT_LANG: Record<string, Lang> = {
386  js: 'js', jsx: 'js', mjs: 'js', cjs: 'js', ts: 'js', tsx: 'js', mts: 'js', cts: 'js',
387  c: 'c', h: 'c', cc: 'c', cpp: 'c', hpp: 'c', cs: 'c', java: 'c', kt: 'c', kts: 'c', go: 'c', rs: 'c',
388  swift: 'c', php: 'c', dart: 'c', scala: 'c', gradle: 'c', groovy: 'c',
389  py: 'py', pyw: 'py', rb: 'py',
390  sh: 'sh', bash: 'sh', zsh: 'sh', fish: 'sh', env: 'sh',
391  ps1: 'ps', psm1: 'ps', psd1: 'ps',
392  json: 'json', jsonc: 'json', json5: 'json', jsonl: 'json',
393  yaml: 'yaml', yml: 'yaml', toml: 'yaml', ini: 'yaml', cfg: 'yaml', conf: 'yaml', properties: 'yaml',
394  css: 'css', scss: 'css', sass: 'css', less: 'css',
395  html: 'html', htm: 'html', xml: 'html', svg: 'html', vue: 'html', svelte: 'html', xaml: 'html', csproj: 'html',
396  md: 'md', markdown: 'md', mdx: 'md',
397  sql: 'sql',
398}
399
400const EXT_LABEL: Record<string, string> = {
401  js: 'JavaScript', jsx: 'JavaScript JSX', mjs: 'JavaScript', cjs: 'JavaScript', ts: 'TypeScript', tsx: 'TypeScript JSX',
402  mts: 'TypeScript', cts: 'TypeScript', c: 'C', h: 'C', cpp: 'C++', cc: 'C++', hpp: 'C++', cs: 'C#', java: 'Java',
403  kt: 'Kotlin', go: 'Go', rs: 'Rust', swift: 'Swift', php: 'PHP', dart: 'Dart', py: 'Python', rb: 'Ruby',
404  sh: 'Shell', bash: 'Bash', zsh: 'Zsh', ps1: 'PowerShell', psm1: 'PowerShell', json: 'JSON', jsonc: 'JSON',
405  jsonl: 'JSON Lines', yaml: 'YAML', yml: 'YAML', toml: 'TOML', ini: 'INI', css: 'CSS', scss: 'SCSS', less: 'Less',
406  html: 'HTML', htm: 'HTML', xml: 'XML', svg: 'SVG', vue: 'Vue', svelte: 'Svelte', md: 'Markdown', mdx: 'MDX',
407  sql: 'SQL', txt: 'Texte',
408}
409
410const extOf = (path: string) => {
411  const name = baseName(path).toLowerCase()
412  const dot = name.lastIndexOf('.')
413  return dot <= 0 ? name : name.slice(dot + 1)
414}
415
416export function langOf(path: string): Lang {
417  const name = baseName(path).toLowerCase()
418  if (name === 'dockerfile' || name === 'makefile' || name.startsWith('.env')) return 'sh'
419  if (name === '.gitignore' || name === '.gitattributes' || name === '.editorconfig') return 'sh'
420  return EXT_LANG[extOf(path)] ?? 'text'
421}
422
423export const languageLabel = (path: string) => EXT_LABEL[extOf(path)] ?? 'Texte brut'
424
425export const isMarkdown = (path: string) => langOf(path) === 'md'
426
427/** Un jeton coloré : début et fin (indices dans la ligne), classe. */
428export type Tok = [number, number, string]
429/**
430 * Ce qui continue d'une ligne à la suivante : '' rien, `bc` un commentaire bloc, `tpl` un
431 * gabarit JS, `tq"` ou `tq'` une triple chaîne Python, `fence` un bloc de code Markdown.
432 */
433export type HlState = string
434
435type Spec = {
436  line: string[]
437  block: [string, string] | null
438  quotes: string
439  template?: boolean
440  triple?: boolean
441  dollar?: boolean
442  ci?: boolean
443  types?: boolean
444  keywords: ReadonlySet<string>
445  controls: ReadonlySet<string>
446  consts: ReadonlySet<string>
447}
448
449const words = (s: string) => new Set(s.split(/\s+/).filter(Boolean))
450
451const SPECS: Partial<Record<Lang, Spec>> = {
452  js: {
453    line: ['//'],
454    block: ['/*', '*/'],
455    quotes: `'"\``,
456    template: true,
457    types: true,
458    keywords: words(
459      'const let var function class extends new this super typeof instanceof in of void delete async await static get set yield interface type enum implements namespace declare readonly public private protected abstract as satisfies keyof infer is unique any number string boolean never unknown object symbol bigint',
460    ),
461    controls: words('if else for while do switch case default break continue return throw try catch finally import export from with'),
462    consts: words('true false null undefined NaN Infinity'),
463  },
464  c: {
465    line: ['//'],
466    block: ['/*', '*/'],
467    quotes: `'"`,
468    types: true,
469    keywords: words(
470      'int float double char void long short unsigned signed struct union enum typedef const static extern auto volatile sizeof class public private protected internal abstract final override virtual new this self super fn let mut pub impl trait mod use crate func var val package interface type namespace using where async await defer go chan map bool string byte rune object dynamic readonly sealed partial operator',
471    ),
472    controls: words('if else for foreach while do switch case default break continue return throw try catch finally goto match loop in as import yield when'),
473    consts: words('true false null nil NULL nullptr None'),
474  },
475  py: {
476    line: ['#'],
477    block: null,
478    quotes: `'"`,
479    triple: true,
480    types: true,
481    keywords: words('def class lambda global nonlocal pass del and or not is in as async await self cls end module begin'),
482    controls: words('if elif else for while break continue return try except finally raise with import from yield assert match case unless until do'),
483    consts: words('True False None nil true false'),
484  },
485  sh: {
486    line: ['#'],
487    block: null,
488    quotes: `'"`,
489    dollar: true,
490    keywords: words('function local export readonly declare echo cd set unset alias source printf eval exec shift trap FROM RUN CMD COPY ADD ENV WORKDIR ENTRYPOINT ARG EXPOSE'),
491    controls: words('if then else elif fi for in do done while until case esac return exit break continue'),
492    consts: words('true false'),
493  },
494  ps: {
495    line: ['#'],
496    block: ['<#', '#>'],
497    quotes: `'"`,
498    dollar: true,
499    ci: true,
500    keywords: words('function param begin process end filter class enum using'),
501    controls: words('if elseif else foreach for while do until switch break continue return throw try catch finally exit in'),
502    consts: words(''),
503  },
504  sql: {
505    line: ['--'],
506    block: ['/*', '*/'],
507    quotes: `'"`,
508    ci: true,
509    keywords: words(
510      'select from where insert into values update set delete create table alter drop index view join left right inner outer full cross on as and or not is in like between order by group having limit offset distinct union all primary key foreign references default unique check constraint begin commit rollback grant revoke function returns language replace trigger exists case when then else end with returning cascade',
511    ),
512    controls: words(''),
513    consts: words('true false null'),
514  },
515}
516
517const IDENT_START = /[A-Za-z_$À-￿]/
518const IDENT = /[\w$À-￿]/
519const NUMBER = /(?:0[xXbBoO][0-9a-fA-F_]+|\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?)[a-zA-Z%]*/y
520const WORD = /[A-Za-z_$À-￿][\w$À-￿]*/y
521const DOLLAR = /\$\{?[\w:?]+\}?/y
522
523/** L'indice du guillemet fermant `q` à partir de `from` (les `\` échappent), ou -1. */
524function closing(line: string, from: number, q: string): number {
525  for (let i = from; i < line.length; i++) {
526    const c = line[i]
527    if (c === '\\') i += 1
528    else if (c === q) return i
529  }
530  return -1
531}
532
533const nextChar = (line: string, from: number) => {
534  for (let i = from; i < line.length; i++) if (line[i] !== ' ' && line[i] !== '\t') return line[i]
535  return ''
536}
537const prevChar = (line: string, from: number) => {
538  for (let i = from; i >= 0; i--) if (line[i] !== ' ' && line[i] !== '\t') return line[i]
539  return ''
540}
541
542function tokenizeCode(line: string, spec: Spec, start: HlState): { toks: Tok[]; state: HlState } {
543  const toks: Tok[] = []
544  const n = line.length
545  let state = start
546  let i = 0
547  if (state === 'bc' && spec.block) {
548    const e = line.indexOf(spec.block[1])
549    if (e < 0) return { toks: [[0, n, 'c']], state }
550    toks.push([0, e + spec.block[1].length, 'c'])
551    i = e + spec.block[1].length
552    state = ''
553  } else if (state === 'tpl') {
554    const e = closing(line, 0, '`')
555    if (e < 0) return { toks: [[0, n, 's']], state }
556    toks.push([0, e + 1, 's'])
557    i = e + 1
558    state = ''
559  } else if (state.startsWith('tq')) {
560    const q = state.slice(2).repeat(3)
561    const e = line.indexOf(q)
562    if (e < 0) return { toks: [[0, n, 's']], state }
563    toks.push([0, e + 3, 's'])
564    i = e + 3
565    state = ''
566  }
567  while (i < n) {
568    const c = line[i]!
569    if (c === ' ' || c === '\t') {
570      i += 1
571      continue
572    }
573    if (spec.line.some((t) => line.startsWith(t, i))) {
574      toks.push([i, n, 'c'])
575      break
576    }
577    if (spec.block && line.startsWith(spec.block[0], i)) {
578      const e = line.indexOf(spec.block[1], i + spec.block[0].length)
579      if (e < 0) {
580        toks.push([i, n, 'c'])
581        state = 'bc'
582        break
583      }
584      toks.push([i, e + spec.block[1].length, 'c'])
585      i = e + spec.block[1].length
586      continue
587    }
588    if (spec.triple && (line.startsWith('"""', i) || line.startsWith("'''", i))) {
589      const q = line.slice(i, i + 3)
590      const e = line.indexOf(q, i + 3)
591      if (e < 0) {
592        toks.push([i, n, 's'])
593        state = `tq${q[0]}`
594        break
595      }
596      toks.push([i, e + 3, 's'])
597      i = e + 3
598      continue
599    }
600    if (spec.quotes.includes(c)) {
601      const e = closing(line, i + 1, c)
602      if (e < 0 && c === '`' && spec.template) {
603        toks.push([i, n, 's'])
604        state = 'tpl'
605        break
606      }
607      const end = e < 0 ? n : e + 1
608      toks.push([i, end, 's'])
609      i = end
610      continue
611    }
612    if (/[0-9]/.test(c) && !IDENT.test(line[i - 1] ?? ' ')) {
613      NUMBER.lastIndex = i
614      const m = NUMBER.exec(line)
615      const len = m ? m[0].length : 1
616      toks.push([i, i + len, 'n'])
617      i += len
618      continue
619    }
620    if (spec.dollar && c === '$') {
621      DOLLAR.lastIndex = i
622      const m = DOLLAR.exec(line)
623      if (m && m[0].length > 1) {
624        toks.push([i, i + m[0].length, 'v'])
625        i += m[0].length
626        continue
627      }
628    }
629    if (IDENT_START.test(c)) {
630      WORD.lastIndex = i
631      const w = WORD.exec(line)?.[0] ?? c
632      const key = spec.ci ? w.toLowerCase() : w
633      let cls = ''
634      if (spec.controls.has(key)) cls = 'K'
635      else if (spec.keywords.has(key)) cls = 'k'
636      else if (spec.consts.has(key)) cls = 'o'
637      else if (nextChar(line, i + w.length) === '(') cls = 'f'
638      else if (prevChar(line, i - 1) === '.') cls = 'p'
639      else if (spec.types && /^[A-Z][a-z0-9]/.test(w)) cls = 't'
640      if (cls) toks.push([i, i + w.length, cls])
641      i += w.length
642      continue
643    }
644    i += 1
645  }
646  return { toks, state }
647}
648
649function tokenizeJson(line: string, start: HlState): { toks: Tok[]; state: HlState } {
650  const toks: Tok[] = []
651  let i = 0
652  let state = start
653  if (state === 'bc') {
654    const e = line.indexOf('*/')
655    if (e < 0) return { toks: [[0, line.length, 'c']], state }
656    toks.push([0, e + 2, 'c'])
657    i = e + 2
658    state = ''
659  }
660  while (i < line.length) {
661    const c = line[i]!
662    if (c === '"') {
663      const e = closing(line, i + 1, '"')
664      const end = e < 0 ? line.length : e + 1
665      toks.push([i, end, nextChar(line, end) === ':' ? 'p' : 's'])
666      i = end
667    } else if (line.startsWith('//', i)) {
668      toks.push([i, line.length, 'c'])
669      break
670    } else if (line.startsWith('/*', i)) {
671      const e = line.indexOf('*/', i + 2)
672      if (e < 0) {
673        toks.push([i, line.length, 'c'])
674        state = 'bc'
675        break
676      }
677      toks.push([i, e + 2, 'c'])
678      i = e + 2
679    } else if (/[-0-9]/.test(c)) {
680      NUMBER.lastIndex = c === '-' ? i + 1 : i
681      const m = NUMBER.exec(line)
682      const len = (m ? m[0].length : 0) + (c === '-' ? 1 : 0)
683      if (len > (c === '-' ? 1 : 0)) toks.push([i, i + len, 'n'])
684      i += Math.max(1, len)
685    } else if (/[tfn]/.test(c)) {
686      const m = /^(true|false|null)\b/.exec(line.slice(i, i + 6))
687      if (m) toks.push([i, i + m[0].length, 'o'])
688      i += m ? m[0].length : 1
689    } else i += 1
690  }
691  return { toks, state }
692}
693
694function tokenizeYaml(line: string): Tok[] {
695  const toks: Tok[] = []
696  const trimmed = line.trimStart()
697  const lead = line.length - trimmed.length
698  if (trimmed.startsWith('#') || trimmed.startsWith(';')) return [[lead, line.length, 'c']]
699  if (/^\[.*\]\s*$/.test(trimmed)) return [[lead, line.length, 'h']]
700  const key = /^(\s*(?:-\s+)?)([^:=#\s][^:=#]*?)\s*[:=](\s|$)/.exec(line)
701  let from = 0
702  if (key) {
703    const ks = key[1]!.length
704    toks.push([ks, ks + key[2]!.length, 'p'])
705    from = key[0].length
706  }
707  const rest = line.slice(from)
708  const comment = rest.search(/\s#/)
709  const valueEnd = comment < 0 ? line.length : from + comment
710  const value = line.slice(from, valueEnd).trim()
711  if (value) {
712    const vs = line.indexOf(value, from)
713    if (/^["']/.test(value)) toks.push([vs, vs + value.length, 's'])
714    else if (/^-?\d[\d_.]*([eE][+-]?\d+)?$/.test(value)) toks.push([vs, vs + value.length, 'n'])
715    else if (/^(true|false|null|yes|no|on|off|~)$/i.test(value)) toks.push([vs, vs + value.length, 'o'])
716  }
717  if (comment >= 0) toks.push([from + comment + 1, line.length, 'c'])
718  return toks
719}
720
721function tokenizeCss(line: string, start: HlState): { toks: Tok[]; state: HlState } {
722  const toks: Tok[] = []
723  let i = 0
724  let state = start
725  if (state === 'bc') {
726    const e = line.indexOf('*/')
727    if (e < 0) return { toks: [[0, line.length, 'c']], state }
728    toks.push([0, e + 2, 'c'])
729    i = e + 2
730    state = ''
731  }
732  const isDecl = /^\s*[-\w]+\s*:/.test(line) && !/[{]/.test(line)
733  while (i < line.length) {
734    const c = line[i]!
735    if (line.startsWith('/*', i)) {
736      const e = line.indexOf('*/', i + 2)
737      if (e < 0) {
738        toks.push([i, line.length, 'c'])
739        state = 'bc'
740        break
741      }
742      toks.push([i, e + 2, 'c'])
743      i = e + 2
744    } else if (line.startsWith('//', i) && (i === 0 || /\s/.test(line[i - 1] ?? ''))) {
745      toks.push([i, line.length, 'c'])
746      break
747    } else if (c === '"' || c === "'") {
748      const e = closing(line, i + 1, c)
749      const end = e < 0 ? line.length : e + 1
750      toks.push([i, end, 's'])
751      i = end
752    } else if (c === '@') {
753      WORD.lastIndex = i + 1
754      const w = WORD.exec(line)?.[0] ?? ''
755      toks.push([i, i + 1 + w.length, 'K'])
756      i += 1 + w.length
757    } else if (c === '#' && isDecl) {
758      const m = /^#[0-9a-fA-F]{3,8}/.exec(line.slice(i, i + 9))
759      const len = m ? m[0].length : 1
760      if (m) toks.push([i, i + len, 'n'])
761      i += len
762    } else if (/[0-9.]/.test(c) && !IDENT.test(line[i - 1] ?? ' ') && /[0-9]/.test(c === '.' ? (line[i + 1] ?? '') : c)) {
763      const m = /^\.?\d[\d.]*[a-zA-Z%]*/.exec(line.slice(i, i + 20))
764      const len = m ? m[0].length : 1
765      toks.push([i, i + len, 'n'])
766      i += len
767    } else if (isDecl && /[-\w]/.test(c) && i < line.indexOf(':')) {
768      const m = /^[-\w]+/.exec(line.slice(i))
769      const len = m ? m[0].length : 1
770      toks.push([i, i + len, 'p'])
771      i += len
772    } else i += 1
773  }
774  return { toks, state }
775}
776
777function tokenizeHtml(line: string, start: HlState): { toks: Tok[]; state: HlState } {
778  const toks: Tok[] = []
779  let i = 0
780  let state = start
781  if (state === 'hc') {
782    const e = line.indexOf('-->')
783    if (e < 0) return { toks: [[0, line.length, 'c']], state }
784    toks.push([0, e + 3, 'c'])
785    i = e + 3
786    state = ''
787  }
788  let inTag = false
789  while (i < line.length) {
790    const c = line[i]!
791    if (line.startsWith('<!--', i)) {
792      const e = line.indexOf('-->', i + 4)
793      if (e < 0) {
794        toks.push([i, line.length, 'c'])
795        state = 'hc'
796        break
797      }
798      toks.push([i, e + 3, 'c'])
799      i = e + 3
800    } else if (c === '<') {
801      const m = /^<\/?[\w:.-]*/.exec(line.slice(i, i + 64))
802      const len = m ? m[0].length : 1
803      toks.push([i, i + len, 'g'])
804      i += len
805      inTag = true
806    } else if (inTag && (c === '>' || line.startsWith('/>', i))) {
807      const len = c === '>' ? 1 : 2
808      toks.push([i, i + len, 'g'])
809      i += len
810      inTag = false
811    } else if (inTag && (c === '"' || c === "'")) {
812      const e = closing(line, i + 1, c)
813      const end = e < 0 ? line.length : e + 1
814      toks.push([i, end, 's'])
815      i = end
816    } else if (inTag && /[\w:@.-]/.test(c)) {
817      const m = /^[\w:@.-]+/.exec(line.slice(i))
818      const len = m ? m[0].length : 1
819      toks.push([i, i + len, 'a'])
820      i += len
821    } else i += 1
822  }
823  return { toks, state }
824}
825
826function tokenizeMarkdown(line: string, start: HlState): { toks: Tok[]; state: HlState } {
827  const isFence = /^\s*(```|~~~)/.test(line)
828  if (start === 'fence') return { toks: [[0, line.length, 'm']], state: isFence ? '' : 'fence' }
829  if (isFence) return { toks: [[0, line.length, 'm']], state: 'fence' }
830  if (/^\s{0,3}#{1,6}(\s|$)/.test(line)) return { toks: [[0, line.length, 'h']], state: '' }
831  if (/^\s*>/.test(line)) return { toks: [[0, line.length, 'c']], state: '' }
832  const toks: Tok[] = []
833  const list = /^\s*([-*+]|\d+[.)])\s/.exec(line)
834  if (list) {
835    const s = line.indexOf(list[1]!)
836    toks.push([s, s + list[1]!.length, 'K'])
837  }
838  const re = /(`[^`]+`)|(\*\*[^*]+\*\*|__[^_]+__)|(\[[^\]]*\]\([^)]*\)|<https?:[^>]+>)/g
839  for (let m = re.exec(line); m; m = re.exec(line)) toks.push([m.index, m.index + m[0].length, m[1] ? 'm' : m[2] ? 'b' : 'l'])
840  return { toks, state: '' }
841}
842
843/** Les jetons colorés d'une ligne, et ce qui continue sur la suivante. */
844export function tokenize(line: string, lang: Lang, state: HlState): { toks: Tok[]; state: HlState } {
845  const spec = SPECS[lang]
846  if (spec) return tokenizeCode(line, spec, state)
847  if (lang === 'json') return tokenizeJson(line, state)
848  if (lang === 'yaml') return { toks: tokenizeYaml(line), state: '' }
849  if (lang === 'css') return tokenizeCss(line, state)
850  if (lang === 'html') return tokenizeHtml(line, state)
851  if (lang === 'md') return tokenizeMarkdown(line, state)
852  return { toks: [], state: '' }
853}
854
855// ─── Markdown rendu ─────────────────────────────────────────────────────────
856
857/** Taille d'un bloc passé à l'élément Markdown (10 000 caractères au plus). */
858const BLOCK_CHARS = 6000
859
860/**
861 * Les blocs d'un document Markdown (paragraphes, titres, blocs de code), avec leur première
862 * ligne : l'aperçu défile bloc par bloc.
863 */
864export function mdBlocks(lines: readonly string[]): { start: number; text: string }[] {
865  const blocks: { start: number; text: string }[] = []
866  let cur: string[] = []
867  let start = 0
868  let fence: string | null = null
869  const flush = () => {
870    if (cur.length) blocks.push(...splitBlock(start, cur))
871    cur = []
872  }
873  lines.forEach((line, i) => {
874    const marker = /^\s*(```|~~~)/.exec(line)?.[1] ?? null
875    if (fence === null && line.trim() === '') return flush()
876    if (!cur.length) start = i
877    cur.push(line)
878    if (marker && fence === null) fence = marker
879    else if (marker && marker === fence) fence = null
880    if (fence === null && /^\s{0,3}#{1,6}\s/.test(line)) flush()
881  })
882  flush()
883  return blocks
884}
885
886function splitBlock(start: number, lines: string[]): { start: number; text: string }[] {
887  const text = lines.join('\n')
888  if (text.length <= BLOCK_CHARS) return [{ start, text }]
889  const out: { start: number; text: string }[] = []
890  const opener = /^\s*(```|~~~).*$/.exec(lines[0] ?? '')?.[0] ?? null
891  let chunk: string[] = []
892  let chunkStart = start
893  let size = 0
894  lines.forEach((line, i) => {
895    const piece = line.length > BLOCK_CHARS ? `${line.slice(0, BLOCK_CHARS - 1)}…` : line
896    if (size + piece.length + 1 > BLOCK_CHARS && chunk.length) {
897      out.push({ start: chunkStart, text: chunk.join('\n') })
898      chunk = opener && i > 0 ? [opener.trim()] : []
899      chunkStart = start + i
900      size = chunk.join('\n').length
901    }
902    chunk.push(piece)
903    size += piece.length + 1
904  })
905  if (chunk.length) out.push({ start: chunkStart, text: chunk.join('\n') })
906  // Un bloc de code coupé en morceaux : chaque morceau garde sa clôture pour s'afficher en code.
907  if (opener) {
908    const fenceMark = /(```|~~~)/.exec(opener)?.[1] ?? '```'
909    for (const part of out) if (!/(```|~~~)\s*$/.test(part.text)) part.text += `\n${fenceMark}`
910  }
911  return out
912}
913
914// ─── Édition ────────────────────────────────────────────────────────────────
915
916/** Un tampon d'édition : ses lignes et le curseur (ligne `cy`, indice `cx` dans la ligne). */
917export type Caret = { lines: string[]; cy: number; cx: number }
918
919/**
920 * L'unité d'indentation du fichier : une tabulation s'il indente surtout ainsi, sinon le
921 * plus petit retrait d'espaces (2, 4 ou 8) ; null quand rien n'est indenté.
922 */
923export function detectIndent(lines: readonly string[]): string | null {
924  let tabs = 0
925  let spaces = 0
926  let smallest = Infinity
927  for (const line of lines.slice(0, 2000)) {
928    if (line.startsWith('\t')) tabs += 1
929    else if (line.startsWith(' ') && line.trim()) {
930      const n = /^ */.exec(line)?.[0].length ?? 0
931      // Les ` * ` des commentaires bloc ne disent rien de l'indentation.
932      if (/^ +\*/.test(line) && n % 2 === 1) continue
933      spaces += 1
934      smallest = Math.min(smallest, n)
935    }
936  }
937  if (tabs > spaces) return '\t'
938  if (!spaces) return null
939  return ' '.repeat(smallest >= 8 ? 8 : smallest >= 4 ? 4 : 2)
940}
941
942/** La largeur d'un cran d'indentation, en colonnes. */
943export const indentStep = (unit: string, tabSize: number) => (unit === '\t' ? tabSize : unit.length)
944
945export function insertText(b: Caret, raw: string): void {
946  const text = raw.replace(/\r\n?/g, '\n')
947  const line = b.lines[b.cy] ?? ''
948  const before = line.slice(0, b.cx)
949  const after = line.slice(b.cx)
950  const parts = text.split('\n')
951  if (parts.length === 1) {
952    b.lines[b.cy] = before + text + after
953    b.cx += text.length
954    return
955  }
956  const last = parts[parts.length - 1]!
957  const middle = parts.slice(1, -1)
958  b.lines.splice(b.cy, 1, before + parts[0], ...middle, last + after)
959  b.cy += parts.length - 1
960  b.cx = last.length
961}
962
963const OPENERS: Record<string, string> = { '{': '}', '[': ']', '(': ')' }
964
965/** Entrée : nouvelle ligne à la même indentation, un cran de plus après `{`, `[`, `(` ou `:`. */
966export function newline(b: Caret, unit: string, lang: Lang): void {
967  const line = b.lines[b.cy] ?? ''
968  const indent = /^[ \t]*/.exec(line)?.[0] ?? ''
969  const before = line.slice(0, b.cx).trimEnd()
970  const prev = before[before.length - 1] ?? ''
971  const next = line[b.cx] ?? ''
972  const opens = prev in OPENERS || (prev === ':' && lang === 'py')
973  if (opens && OPENERS[prev] === next) {
974    insertText(b, `\n${indent}${unit}\n${indent}`)
975    b.cy -= 1
976    b.cx = indent.length + unit.length
977    return
978  }
979  insertText(b, `\n${indent}${opens ? unit : ''}`)
980}
981
982/** Retour arrière ; dans une indentation d'espaces, recule d'un cran (`step` colonnes). */
983export function deleteBack(b: Caret, step: number): void {
984  if (b.cx === 0) {
985    if (b.cy === 0) return
986    const prev = b.lines[b.cy - 1] ?? ''
987    b.lines.splice(b.cy - 1, 2, prev + (b.lines[b.cy] ?? ''))
988    b.cy -= 1
989    b.cx = prev.length
990    return
991  }
992  const line = b.lines[b.cy] ?? ''
993  const head = line.slice(0, b.cx)
994  let count = 1
995  if (/^ +$/.test(head)) count = ((head.length - 1) % step) + 1
996  else if (b.cx >= 2 && /[\uDC00-\uDFFF]/.test(line[b.cx - 1] ?? '') && /[\uD800-\uDBFF]/.test(line[b.cx - 2] ?? '')) count = 2
997  b.lines[b.cy] = line.slice(0, b.cx - count) + line.slice(b.cx)
998  b.cx -= count
999}
1000
1001export function deleteForward(b: Caret): void {
1002  const line = b.lines[b.cy] ?? ''
1003  if (b.cx >= line.length) {
1004    if (b.cy >= b.lines.length - 1) return
1005    b.lines.splice(b.cy, 2, line + (b.lines[b.cy + 1] ?? ''))
1006    return
1007  }
1008  const count = /[\uD800-\uDBFF]/.test(line[b.cx] ?? '') ? 2 : 1
1009  b.lines[b.cy] = line.slice(0, b.cx) + line.slice(b.cx + count)
1010}
1011
1012/** Tab : une tabulation, ou des espaces jusqu'au prochain cran d'indentation. */
1013export function indent(b: Caret, unit: string, tabSize: number): void {
1014  if (unit === '\t') return insertText(b, '\t')
1015  const step = unit.length
1016  const col = colOf(glyphs(b.lines[b.cy] ?? '', tabSize), b.cx)
1017  insertText(b, ' '.repeat(step - (col % step)))
1018}
1019
1020/** Maj+Tab : retire un cran d'indentation au début de la ligne. */
1021export function outdent(b: Caret, unit: string, tabSize: number): void {
1022  const line = b.lines[b.cy] ?? ''
1023  let count = 0
1024  if (line.startsWith('\t')) count = 1
1025  else count = Math.min(/^ */.exec(line)?.[0].length ?? 0, unit === '\t' ? tabSize : unit.length)
1026  if (!count) return
1027  b.lines[b.cy] = line.slice(count)
1028  b.cx = Math.max(0, b.cx - count)
1029}
1030
1031const isWordChar = (c: string) => /[\wÀ-￿]/.test(c)
1032
1033export function wordLeft(line: string, cx: number): number {
1034  let i = cx
1035  while (i > 0 && !isWordChar(line[i - 1] ?? '')) i -= 1
1036  while (i > 0 && isWordChar(line[i - 1] ?? '')) i -= 1
1037  return i
1038}
1039
1040export function wordRight(line: string, cx: number): number {
1041  let i = cx
1042  while (i < line.length && !isWordChar(line[i] ?? '')) i += 1
1043  while (i < line.length && isWordChar(line[i] ?? '')) i += 1
1044  return i
1045}
1046
1047/** Début : le premier caractère non blanc, ou le début de ligne s'il y est déjà. */
1048export function smartHome(line: string, cx: number): number {
1049  const first = /^[ \t]*/.exec(line)?.[0].length ?? 0
1050  return cx === first ? 0 : first
1051}
1052
1053/** Les occurrences de `query` (sans tenir compte de la casse) : ligne, début, fin. */
1054export function findMatches(lines: readonly string[], query: string, max = 5000): [number, number, number][] {
1055  const out: [number, number, number][] = []
1056  const q = query.toLowerCase()
1057  if (!q) return out
1058  for (let y = 0; y < lines.length && out.length < max; y++) {
1059    const line = (lines[y] ?? '').toLowerCase()
1060    for (let i = line.indexOf(q); i >= 0 && out.length < max; i = line.indexOf(q, i + q.length)) out.push([y, i, i + q.length])
1061  }
1062  return out
1063}
1064
1065// ─── Menus ──────────────────────────────────────────────────────────────────
1066
1067export type MenuItem = {
1068  id: string
1069  label: string
1070  /** Coche : true cochée, false décochée (la place reste), absente pour une action. */
1071  check?: boolean
1072  hint?: string
1073  sub?: MenuItem[]
1074  isSeparator?: boolean
1075  isDisabled?: boolean
1076}
1077
1078const SEP: MenuItem = { id: '-', label: '', isSeparator: true }
1079
1080export const explorerLabel = (os: Os) =>
1081  os === 'windows' ? "Afficher dans l'Explorateur" : os === 'mac' ? 'Afficher dans le Finder' : 'Afficher dans le gestionnaire de fichiers'
1082
1083/** Le menu ⋮ : les options d'affichage, cochées comme dans l'app desktop. */
1084export function viewMenu(o: Options): MenuItem[] {
1085  return [
1086    { id: 'opt:showTree', label: "Afficher l'arborescence de fichiers", check: o.showTree, hint: 'Ctrl+Maj+Y' },
1087    SEP,
1088    { id: 'opt:previewTabs', label: "Onglets d'aperçu", check: o.previewTabs },
1089    { id: 'opt:autoSave', label: 'Enregistrement automatique', check: o.autoSave },
1090    { id: 'opt:tabIndents', label: 'La touche Tab indente', check: o.tabIndents },
1091    SEP,
1092    { id: 'opt:wordWrap', label: 'Retour à la ligne automatique', check: o.wordWrap },
1093    { id: 'opt:indentGuides', label: "Repères d'indentation", check: o.indentGuides },
1094    { id: 'opt:showWhitespace', label: 'Afficher les espaces', check: o.showWhitespace },
1095    { id: 'opt:scrollBeyondLastLine', label: 'Défilement au-delà de la fin', check: o.scrollBeyondLastLine },
1096    {
1097      id: 'tabSize',
1098      label: 'Taille de tabulation',
1099      hint: String(o.tabSize),
1100      sub: TAB_SIZES.map((n) => ({ id: `tabSize:${n}`, label: String(n), check: o.tabSize === n })),
1101    },
1102  ]
1103}
1104
1105const copyMenu = (isDir: boolean): MenuItem => ({
1106  id: 'copy',
1107  label: 'Copier',
1108  sub: [
1109    { id: 'copy:abs', label: 'Chemin absolu' },
1110    { id: 'copy:rel', label: 'Chemin relatif' },
1111    { id: 'copy:name', label: isDir ? 'Nom du dossier' : 'Nom du fichier' },
1112  ],
1113})
1114
1115/** Les applications qui ouvrent aussi un dossier. */
1116const FOLDER_APPS = new Set(['default', 'code', 'cursor'])
1117
1118const openInMenu = (apps: readonly App[], isDir: boolean): MenuItem => {
1119  const list = apps.filter((a) => !isDir || FOLDER_APPS.has(a.id))
1120  return {
1121    id: 'openIn',
1122    label: 'Ouvrir dans',
1123    sub: list.length ? list.map((a) => ({ id: `openIn:${a.id}`, label: a.label })) : [{ id: 'none', label: 'Recherche des applications…', isDisabled: true }],
1124  }
1125}
1126
1127/** Le menu d'une entrée de l'arborescence (clic droit). */
1128export function entryMenu(isDir: boolean, apps: readonly App[], os: Os): MenuItem[] {
1129  return [
1130    { id: 'attach', label: 'Joindre comme contexte' },
1131    copyMenu(isDir),
1132    SEP,
1133    openInMenu(apps, isDir),
1134    { id: 'reveal', label: explorerLabel(os) },
1135    ...(isDir ? [] : [SEP, { id: 'copyContent', label: 'Copier le contenu du fichier' }]),
1136  ]
1137}
1138
1139/** Le menu d'un onglet (clic droit). */
1140export function tabMenu(isPreview: boolean, apps: readonly App[], os: Os): MenuItem[] {
1141  return [
1142    { id: 'attach', label: 'Joindre comme contexte' },
1143    copyMenu(false),
1144    SEP,
1145    openInMenu(apps, false),
1146    { id: 'reveal', label: explorerLabel(os) },
1147    SEP,
1148    { id: 'revealInTree', label: "Afficher dans l'arborescence de fichiers" },
1149    SEP,
1150    { id: 'find', label: 'Rechercher dans le fichier', hint: 'Ctrl+F' },
1151    { id: 'copyContent', label: 'Copier le contenu du fichier' },
1152    { id: 'reload', label: 'Recharger le fichier' },
1153    SEP,
1154    { id: 'keepOpen', label: 'Garder ouvert', isDisabled: !isPreview },
1155    { id: 'close', label: 'Fermer le fichier' },
1156  ]
1157}
1158
1159/** La largeur intérieure d'un menu : coche, libellé, indice ou flèche. */
1160export function menuWidth(items: readonly MenuItem[]): number {
1161  let w = 10
1162  for (const it of items) {
1163    if (it.isSeparator) continue
1164    const tail = it.sub ? 2 + (it.hint ? strWidth(it.hint) + 1 : 0) : it.hint ? strWidth(it.hint) + 2 : 0
1165    w = Math.max(w, 2 + strWidth(it.label) + tail + 1)
1166  }
1167  return w + 1
1168}
1169
hooks/browser.tsx 1983 lines
1// La région interactive du navigateur de fichiers : barre d'onglets, arborescence, recherche
2// de fichiers, éditeur, menus. Elle dessine tout elle-même, rangée par rangée, et lit le
3// clavier (après un clic : Échap rend le focus au prompt) et la souris (clic, double clic,
4// clic droit, glisser). Elle n'a pas `$` : fichiers, presse-papiers et prompt passent par des
5// messages au module des hooks (register.tsx), renvoyés tant qu'ils ne sont pas accusés (`ack`).
6//
7// Un seul modèle par instance, gardé au niveau du module : les gestionnaires et la minuterie
8// le lisent toujours à jour, même quand deux touches tombent dans la même image.
9
10import type { ClientKeyEvent, ClientModule, ClientPointerEvent, ClientSurface } from 'claude-code'
11
12import type { DocView, Op, Sent, View } from '../types'
13import {
14  type Caret,
15  type Glyph,
16  type HlState,
17  type MenuItem,
18  type TreeRow,
19  ancestors,
20  touchedMarks,
21  baseName,
22  clamp,
23  colOf,
24  decodeRows,
25  deleteBack,
26  deleteForward,
27  detectIndent,
28  indentStep,
29  entryMenu,
30  findMatches,
31  glyphs,
32  glyphsWidth,
33  indent,
34  indexAt,
35  insertText,
36  isMarkdown,
37  langOf,
38  languageLabel,
39  mdBlocks,
40  menuWidth,
41  newline,
42  outdent,
43  pad,
44  parentOf,
45  smartHome,
46  strWidth,
47  tabMenu,
48  tokenize,
49  viewMenu,
50  wordLeft,
51  wordRight,
52  wrapRows,
53  fit,
54} from './model.ts'
55
56// ─── Modèle local ───────────────────────────────────────────────────────────
57
58type Focus = 'tree' | 'search' | 'editor' | 'find'
59
60type Snap = { text: string; cy: number; cx: number }
61
62type Buf = Caret & {
63  path: string
64  /** Colonne visée par haut et bas (dans la rangée), -1 sans visée. */
65  want: number
66  /** Première rangée visuelle affichée (première ligne pour un fichier volumineux). */
67  top: number
68  /** Décalage horizontal, sans retour à la ligne. */
69  left: number
70  rev: number
71  dirty: boolean
72  /** Monte à chaque modification : clé des caches. */
73  ver: number
74  lastEdit: number
75  draftSent: boolean
76  pinSent: boolean
77  /** Le fichier a changé sur le disque pendant des modifications. */
78  conflict: boolean
79  undo: Snap[]
80  redo: Snap[]
81  lastKind: string
82  lastKindAt: number
83  /** Unité d'indentation lue dans le fichier ; null : des espaces de la taille de tabulation. */
84  detected: string | null
85  readOnly: boolean
86  /** Fichier volumineux : nombre de lignes et fenêtre reçue. */
87  large: { count: number; from: number; lines: string[]; asked: number } | null
88  wrapKey: string
89  prefix: number[]
90  hlKey: string
91  hlStates: HlState[]
92  matchKey: string
93  matches: [number, number, number][]
94  mdKey: string
95  blocks: { start: number; text: string }[]
96}
97
98type SubMenu = { items: MenuItem[]; x: number; y: number; w: number; sel: number }
99type Menu = SubMenu & { kind: 'view' | 'entry' | 'tab'; path: string; isDir: boolean; sub: SubMenu | null }
100
101type Zone = { x0: number; x1: number; y0: number; y1: number; id: string; data?: unknown }
102
103type Layout = {
104  W: number
105  H: number
106  treeShown: boolean
107  contentShown: boolean
108  S: number
109  cx0: number
110  CW: number
111  listY: number
112  listH: number
113  edY: number
114  edH: number
115  gutter: number
116  textX: number
117  textW: number
118}
119
120type Model = {
121  inst: string
122  seq: number
123  outbox: Sent[]
124  needPost: boolean
125  lastPost: number
126  /** Horloge de la région, en ms : avancée par sa minuterie (celle du dessin, que les tests avancent). */
127  clock: number
128  v: number
129  props: View
130  surface: ClientSurface<St>
131  focus: Focus
132  treeSel: string | null
133  treeTop: number
134  rowsRef: readonly string[] | null
135  rows: TreeRow[]
136  search: { text: string; cx: number; sel: number; top: number }
137  bufs: Map<string, Buf>
138  menu: Menu | null
139  zones: Zone[]
140  lastClick: { t: number; x: number; y: number } | null
141  confirm: string | null
142  find: { open: boolean; text: string; cx: number; idx: number }
143  mdSource: Set<string>
144  mdTop: Map<string, number>
145  drag: { kind: 'split' | 'sbTree' | 'sbEdit' } | null
146  sideW: number | null
147  wheelN: number
148  revealN: number
149  pendingReveal: string | null
150  tabStart: number
151  layout: Layout | null
152}
153
154type St = { inst: string; v: number }
155
156let M: Model | null = null
157
158const now = () => Date.now()
159
160// ─── Couleurs ───────────────────────────────────────────────────────────────
161
162const ACCENT = 'cyan'
163/** La marque d'un fichier que Claude a modifié depuis le dernier message (◆), et de ses dossiers (◇). */
164const CLAUDE_MARK = 'magenta'
165const SYNTAX: Record<string, string> = {
166  c: '#6A9955',
167  s: '#CE9178',
168  n: '#B5CEA8',
169  k: '#569CD6',
170  K: '#C586C0',
171  t: '#4EC9B0',
172  f: '#DCDCAA',
173  p: '#9CDCFE',
174  h: '#569CD6',
175  b: '#D7BA7D',
176  m: '#CE9178',
177  l: '#4EC9B0',
178  g: '#569CD6',
179  a: '#9CDCFE',
180  v: '#9CDCFE',
181  o: '#569CD6',
182}
183// Les caractères de contrôle qu'un Text refuse (un Markdown garde tabulation et saut de ligne).
184const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g
185const CONTROL_MD = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g
186
187const MATCH_BG = '#5A4A1E'
188const CURRENT_BG = '#B5892E'
189
190// ─── Entrée de la région ────────────────────────────────────────────────────
191
192const Browser: ClientModule<View, St> = (props, surface) => {
193  const state = surface.state
194  if (!M || !state || state.inst !== M.inst) {
195    M = createModel(props, surface, state?.inst)
196    if (!state) {
197      surface.setState({ inst: M.inst, v: 0 })
198      surface.every(TICK, tick)
199    }
200  }
201  const m = M
202  m.props = props
203  m.surface = surface
204  sync(m)
205  surface.onKey((k) => run(m, () => onKey(m, k)))
206  surface.onPointer((p) => run(m, () => onPointer(m, p)))
207  return draw(m)
208}
209
210export default Browser
211
212function createModel(props: View, surface: ClientSurface<St>, inst?: string): Model {
213  return {
214    inst: inst ?? `${now().toString(36)}${Math.random().toString(36).slice(2, 8)}`,
215    seq: 0,
216    outbox: [],
217    needPost: false,
218    lastPost: 0,
219    clock: 0,
220    v: 0,
221    props,
222    surface,
223    focus: props.active ? 'editor' : 'tree',
224    treeSel: null,
225    treeTop: 0,
226    rowsRef: null,
227    rows: [],
228    search: { text: '', cx: 0, sel: 0, top: 0 },
229    bufs: new Map(),
230    menu: null,
231    zones: [],
232    lastClick: null,
233    confirm: null,
234    find: { open: false, text: '', cx: 0, idx: 0 },
235    mdSource: new Set(),
236    mdTop: new Map(),
237    drag: null,
238    sideW: null,
239    wheelN: props.wheel.n,
240    revealN: props.reveal.n,
241    pendingReveal: null,
242    tabStart: 0,
243    layout: null,
244  }
245}
246
247/** Exécute un gestionnaire, envoie les demandes en attente et redessine s'il a changé quelque chose. */
248function run(m: Model, fn: () => boolean | void) {
249  const changed = fn()
250  flush(m)
251  if (changed !== false) redraw(m)
252}
253
254function redraw(m: Model) {
255  m.v += 1
256  m.surface.setState({ inst: m.inst, v: m.v })
257}
258
259// ─── Messages au module des hooks ───────────────────────────────────────────
260
261function send(m: Model, op: Op) {
262  // Une demande qui en remplace une autre encore en file : seule la dernière compte.
263  if (op.k === 'find') m.outbox = m.outbox.filter((o) => o.k !== 'find')
264  if (op.k === 'draft' || op.k === 'save') m.outbox = m.outbox.filter((o) => !(o.k === 'draft' && o.path === op.path))
265  if (op.k === 'window') m.outbox = m.outbox.filter((o) => !(o.k === 'window' && o.path === op.path))
266  m.seq += 1
267  m.outbox.push({ ...op, seq: m.seq } as Sent)
268  m.needPost = true
269}
270
271/** Un envoi par image au plus, borné en taille : le reste part après l'accusé. */
272function flush(m: Model) {
273  if (!m.needPost || !m.outbox.length) return
274  m.needPost = false
275  m.lastPost = m.clock
276  const ops: Sent[] = []
277  let size = 0
278  for (const op of m.outbox) {
279    const s = JSON.stringify(op).length
280    if (ops.length && size + s > 90_000) break
281    ops.push(op)
282    size += s
283  }
284  m.surface.post({ inst: m.inst, ops } as never)
285}
286
287const TICK = 150
288
289/** Toutes les 150 ms : brouillons, enregistrement automatique, renvoi des demandes non accusées. */
290function tick() {
291  const m = M
292  if (!m) return
293  m.clock += TICK
294  const t = m.clock
295  let changed = false
296  for (const b of m.bufs.values()) {
297    if (!b.dirty || b.readOnly) continue
298    if (m.props.options.autoSave && !b.conflict && t - b.lastEdit >= 1000) {
299      save(m, b)
300      changed = true
301    } else if (!b.draftSent && t - b.lastEdit >= 700) {
302      send(m, { k: 'draft', path: b.path, text: b.lines.join('\n') })
303      b.draftSent = true
304    }
305  }
306  if (m.outbox.length && t - m.lastPost > 1000) m.needPost = true
307  flush(m)
308  if (changed) redraw(m)
309}
310
311function save(m: Model, b: Buf) {
312  if (b.readOnly) return
313  send(m, { k: 'save', path: b.path, text: b.lines.join('\n') })
314  b.dirty = false
315  b.draftSent = true
316}
317
318// ─── Synchronisation avec les props ─────────────────────────────────────────
319
320function sync(m: Model) {
321  const p = m.props
322  if (p.ack.inst === m.inst) m.outbox = m.outbox.filter((o) => o.seq > p.ack.seq)
323  if (p.rows !== m.rowsRef) {
324    m.rows = decodeRows(p.rows)
325    m.rowsRef = p.rows
326  }
327  // Onglets fermés : leurs tampons partent avec eux.
328  const open = new Set(p.tabs.map((t) => t.path))
329  for (const path of m.bufs.keys()) if (!open.has(path)) m.bufs.delete(path)
330  if (m.confirm && !open.has(m.confirm)) m.confirm = null
331  if (p.doc) syncDoc(m, p.doc)
332  if (!p.active && (m.focus === 'editor' || m.focus === 'find')) m.focus = 'tree'
333  if (p.wheel.n !== m.wheelN) {
334    m.wheelN = p.wheel.n
335    wheel(m, p.wheel.by, p.wheel.column)
336  }
337  if (p.reveal.n !== m.revealN) {
338    m.revealN = p.reveal.n
339    m.pendingReveal = p.reveal.path
340    m.search = { text: '', cx: 0, sel: 0, top: 0 }
341    m.focus = 'tree'
342  }
343  if (m.pendingReveal !== null) {
344    const i = m.rows.findIndex((r) => r.path === m.pendingReveal && r.kind !== 'x' && r.kind !== 'l')
345    if (i >= 0) {
346      m.treeSel = m.pendingReveal
347      m.pendingReveal = null
348      showTreeRow(m, i)
349    }
350  }
351}
352
353function syncDoc(m: Model, d: DocView) {
354  let b = m.bufs.get(d.path)
355  if (d.state === 'large' && d.window) {
356    const lines = d.window.text.split('\n')
357    if (!b) {
358      b = newBuf(m, d, [''])
359      b.readOnly = true
360      m.bufs.set(d.path, b)
361    }
362    b.large = { count: d.lineCount, from: d.window.from, lines, asked: b.large?.asked ?? -1 }
363    b.rev = d.rev
364    return
365  }
366  if (d.state !== 'ready') return
367  if (!b || b.large) {
368    b = newBuf(m, d, d.text.split('\n'))
369    m.bufs.set(d.path, b)
370    return
371  }
372  // Tant qu'une réponse à la bannière (garder, recharger) attend son accusé, elle reste fermée.
373  const isAnswered = m.outbox.some((o) => (o.k === 'keep' || o.k === 'reload') && o.path === d.path)
374  if (d.changedOnDisk && !isAnswered) b.conflict = true
375  if (d.rev === b.rev) return
376  if (d.origin.startsWith(`save:${m.inst}:`)) {
377    // Notre propre enregistrement : rien à relire.
378    b.rev = d.rev
379    return
380  }
381  if (!b.dirty) {
382    replaceText(b, d.text)
383    b.rev = d.rev
384    b.conflict = false
385    return
386  }
387  // Modifié sur le disque pendant nos modifications : la bannière propose de choisir.
388  b.conflict = true
389}
390
391function newBuf(m: Model, d: DocView, lines: string[]): Buf {
392  return {
393    path: d.path,
394    lines,
395    cy: 0,
396    cx: 0,
397    want: -1,
398    top: 0,
399    left: 0,
400    rev: d.rev,
401    dirty: d.isDirty,
402    ver: 0,
403    lastEdit: 0,
404    draftSent: true,
405    pinSent: false,
406    conflict: d.changedOnDisk,
407    undo: [],
408    redo: [],
409    lastKind: '',
410    lastKindAt: 0,
411    detected: detectIndent(lines),
412    readOnly: false,
413    large: null,
414    wrapKey: '',
415    prefix: [],
416    hlKey: '',
417    hlStates: [],
418    matchKey: '',
419    matches: [],
420    mdKey: '',
421    blocks: [],
422  }
423}
424
425function replaceText(b: Buf, text: string) {
426  b.lines = text.split('\n')
427  b.cy = clamp(b.cy, 0, b.lines.length - 1)
428  b.cx = clamp(b.cx, 0, (b.lines[b.cy] ?? '').length)
429  b.ver += 1
430  b.dirty = false
431}
432
433// ─── Disposition ────────────────────────────────────────────────────────────
434
435/** L'unité d'indentation d'un tampon : celle du fichier, sinon la taille de tabulation. */
436const unitOf = (m: Model, b: Buf) => b.detected ?? ' '.repeat(m.props.options.tabSize)
437
438const activeBuf = (m: Model) => (m.props.active ? m.bufs.get(m.props.active) : undefined)
439const isRendered = (m: Model, path: string) => isMarkdown(path) && !m.mdSource.has(path)
440
441function computeLayout(m: Model): Layout {
442  const W = Math.max(24, m.surface.columns || m.props.columns || 80)
443  const H = Math.max(6, m.surface.rows || m.props.lines || 24)
444  const o = m.props.options
445  const hasDoc = Boolean(m.props.active)
446  const narrow = W < 70
447  let treeShown = o.showTree
448  let contentShown = true
449  if (narrow) {
450    // Étroit : l'arborescence ou le fichier, pas les deux.
451    treeShown = o.showTree && (!hasDoc || m.focus === 'tree' || m.focus === 'search')
452    contentShown = !treeShown
453  }
454  const auto = o.sidebarWidth || Math.round(W * 0.28)
455  const S = treeShown ? (narrow ? W : clamp(m.sideW ?? auto, 16, W - 40)) : 0
456  const cx0 = treeShown && contentShown ? S + 1 : 0
457  const CW = contentShown ? W - cx0 : 0
458  const b = activeBuf(m)
459  const banners = (b?.conflict ? 1 : 0) + (m.confirm ? 1 : 0) + (m.find.open && b && !b.large ? 1 : 0)
460  const edY = 2 + banners
461  const edH = Math.max(1, H - 1 - edY)
462  const count = b ? (b.large ? b.large.count : b.lines.length) : 1
463  const gutter = String(Math.max(count, 9)).length + 2
464  return { W, H, treeShown, contentShown, S, cx0, CW, listY: 2, listH: Math.max(1, H - 2), edY, edH, gutter, textX: cx0 + gutter, textW: Math.max(4, CW - gutter - 1) }
465}
466
467// ─── Caches de l'éditeur ────────────────────────────────────────────────────
468
469/** Première rangée visuelle de chaque ligne ; `prefix[n]` est le total. */
470function prefixOf(m: Model, b: Buf, L: Layout): number[] {
471  const o = m.props.options
472  const wrap = o.wordWrap && !b.large
473  const key = `${b.ver}:${wrap ? L.textW : 0}:${o.tabSize}`
474  if (b.wrapKey === key) return b.prefix
475  const n = b.large ? b.large.count : b.lines.length
476  const prefix = new Array<number>(n + 1)
477  prefix[0] = 0
478  for (let i = 0; i < n; i++) {
479    const rows = wrap ? wrapRows(glyphs(b.lines[i] ?? '', o.tabSize), L.textW).length : 1
480    prefix[i + 1] = prefix[i]! + rows
481  }
482  b.wrapKey = key
483  b.prefix = prefix
484  return prefix
485}
486
487function lineAtRow(prefix: number[], vr: number): number {
488  let lo = 0
489  let hi = prefix.length - 2
490  while (lo < hi) {
491    const mid = (lo + hi + 1) >> 1
492    if (prefix[mid]! <= vr) lo = mid
493    else hi = mid - 1
494  }
495  return Math.max(0, lo)
496}
497
498function lineText(b: Buf, i: number): string | null {
499  if (!b.large) return b.lines[i] ?? ''
500  const j = i - b.large.from
501  return j >= 0 && j < b.large.lines.length ? (b.large.lines[j] ?? '') : null
502}
503
504/** L'état de coloration au début de la ligne `upTo`, calculé depuis le haut et gardé. */
505function hlStateAt(b: Buf, upTo: number): HlState {
506  const lang = langOf(b.path)
507  const key = `${b.ver}:${lang}`
508  if (b.hlKey !== key) {
509    b.hlKey = key
510    b.hlStates = ['']
511  }
512  if (b.large) return ''
513  while (b.hlStates.length <= upTo && b.hlStates.length <= b.lines.length) {
514    const i = b.hlStates.length - 1
515    b.hlStates.push(tokenize(b.lines[i] ?? '', lang, b.hlStates[i] ?? '').state)
516  }
517  return b.hlStates[Math.min(upTo, b.hlStates.length - 1)] ?? ''
518}
519
520function matchesOf(m: Model, b: Buf): [number, number, number][] {
521  if (!m.find.open || !m.find.text || b.large) return []
522  const key = `${b.ver}:${m.find.text}`
523  if (b.matchKey !== key) {
524    b.matchKey = key
525    b.matches = findMatches(b.lines, m.find.text)
526  }
527  return b.matches
528}
529
530function blocksOf(b: Buf) {
531  const key = String(b.ver)
532  if (b.mdKey !== key) {
533    b.mdKey = key
534    b.blocks = mdBlocks(b.lines)
535  }
536  return b.blocks
537}
538
539// ─── Curseur et défilement ──────────────────────────────────────────────────
540
541/** Rangée visuelle et colonne (dans la rangée) du curseur. */
542function visualOf(m: Model, b: Buf, L: Layout): { vr: number; col: number } {
543  const prefix = prefixOf(m, b, L)
544  const gl = glyphs(b.lines[b.cy] ?? '', m.props.options.tabSize)
545  const c = colOf(gl, b.cx)
546  if (!m.props.options.wordWrap) return { vr: b.cy, col: c }
547  const starts = wrapRows(gl, L.textW)
548  let sub = 0
549  for (let s = 1; s < starts.length; s++) if ((gl[starts[s]!]?.i ?? Infinity) <= b.cx) sub = s
550  return { vr: (prefix[b.cy] ?? 0) + sub, col: c - (gl[starts[sub]!]?.col ?? 0) }
551}
552
553/** Place le curseur à la rangée visuelle `vr`, colonne `col` dans la rangée. */
554function placeAt(m: Model, b: Buf, L: Layout, vr: number, col: number) {
555  const prefix = prefixOf(m, b, L)
556  const total = prefix[prefix.length - 1] ?? 1
557  const row = clamp(vr, 0, Math.max(0, total - 1))
558  const li = lineAtRow(prefix, row)
559  const line = b.lines[li] ?? ''
560  const gl = glyphs(line, m.props.options.tabSize)
561  b.cy = li
562  if (!m.props.options.wordWrap) {
563    b.cx = indexAt(gl, col, line.length)
564    return
565  }
566  const starts = wrapRows(gl, L.textW)
567  const sub = clamp(row - (prefix[li] ?? 0), 0, starts.length - 1)
568  const from = starts[sub] ?? 0
569  const to = starts[sub + 1] ?? gl.length
570  const part = gl.slice(from, to)
571  const base = gl[from]?.col ?? 0
572  const ci = indexAt(part, base + col, line.length)
573  // Au-delà de la fin d'une rangée qui n'est pas la dernière : le curseur reste sur elle.
574  b.cx = to < gl.length && ci >= (gl[to]?.i ?? line.length) ? (part[part.length - 1]?.i ?? 0) : ci
575}
576
577function maxTop(m: Model, b: Buf, L: Layout): number {
578  const total = b.large ? b.large.count : (prefixOf(m, b, L)[b.lines.length] ?? 1)
579  return m.props.options.scrollBeyondLastLine ? Math.max(0, total - 1) : Math.max(0, total - L.edH)
580}
581
582function showCursor(m: Model, b: Buf) {
583  const L = m.layout
584  if (!L || b.large) return
585  const { vr } = visualOf(m, b, L)
586  if (vr < b.top) b.top = vr
587  if (vr >= b.top + L.edH) b.top = vr - L.edH + 1
588  if (!m.props.options.wordWrap) {
589    const c = colOf(glyphs(b.lines[b.cy] ?? '', m.props.options.tabSize), b.cx)
590    if (c < b.left) b.left = Math.max(0, c - 4)
591    if (c >= b.left + L.textW) b.left = c - L.textW + 5
592  } else b.left = 0
593}
594
595function scrollEditor(m: Model, b: Buf, by: number) {
596  const L = m.layout
597  if (!L) return
598  if (b.large) {
599    b.top = clamp(b.top + by, 0, maxTop(m, b, L))
600    return
601  }
602  b.top = clamp(b.top + by, 0, maxTop(m, b, L))
603}
604
605function moveVertical(m: Model, b: Buf, by: number) {
606  const L = m.layout
607  if (!L) return
608  if (b.large) return scrollEditor(m, b, by)
609  const { vr, col } = visualOf(m, b, L)
610  if (b.want < 0) b.want = col
611  placeAt(m, b, L, vr + by, b.want)
612  showCursor(m, b)
613}
614
615// ─── Édition ────────────────────────────────────────────────────────────────
616
617const snap = (b: Buf): Snap => ({ text: b.lines.join('\n'), cy: b.cy, cx: b.cx })
618
619function restore(b: Buf, s: Snap) {
620  b.lines = s.text.split('\n')
621  b.cy = clamp(s.cy, 0, b.lines.length - 1)
622  b.cx = clamp(s.cx, 0, (b.lines[b.cy] ?? '').length)
623}
624
625function touched(m: Model, b: Buf) {
626  b.ver += 1
627  b.dirty = true
628  b.lastEdit = m.clock
629  b.draftSent = false
630  b.want = -1
631  if (!b.pinSent && m.props.tabs.some((t) => t.path === b.path && t.isPreview)) {
632    send(m, { k: 'pin', path: b.path })
633    b.pinSent = true
634  }
635  showCursor(m, b)
636}
637
638/** Une modification ; les frappes d'une même sorte, rapprochées, s'annulent ensemble. */
639function edit(m: Model, b: Buf, kind: string, fn: (c: Caret) => void) {
640  if (b.readOnly) return
641  const t = now()
642  if (kind !== b.lastKind || t - b.lastKindAt > 1000 || kind === 'enter' || kind === 'paste') {
643    b.undo.push(snap(b))
644    if (b.undo.length > 200) b.undo.shift()
645  }
646  b.lastKind = kind
647  b.lastKindAt = t
648  b.redo = []
649  fn(b)
650  touched(m, b)
651}
652
653function undo(m: Model, b: Buf, isRedo: boolean) {
654  const from = isRedo ? b.redo : b.undo
655  const to = isRedo ? b.undo : b.redo
656  const s = from.pop()
657  if (!s || b.readOnly) return
658  to.push(snap(b))
659  restore(b, s)
660  b.lastKind = ''
661  touched(m, b)
662}
663
664// ─── Clavier ────────────────────────────────────────────────────────────────
665
666const SPECIAL = new Set([
667  'up', 'down', 'left', 'right', 'return', 'enter', 'tab', 'backspace', 'delete', 'pageup', 'pagedown', 'home', 'end',
668  'escape', 'insert', 'clear', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12',
669])
670
671function keyName(k: ClientKeyEvent): string {
672  const key = k.key === 'enter' ? 'return' : k.key === 'space' ? ' ' : k.key
673  return SPECIAL.has(key.toLowerCase()) ? key.toLowerCase() : key
674}
675
676const isText = (k: ClientKeyEvent, key: string) => !k.ctrl && !k.meta && key.length > 0 && !SPECIAL.has(key)
677
678function onKey(m: Model, k: ClientKeyEvent): boolean | void {
679  const key = keyName(k)
680  const lower = key.toLowerCase()
681  if (m.menu) return menuKey(m, key)
682  if (m.confirm) return confirmKey(m, key)
683  // Raccourcis valables partout.
684  if (k.ctrl && lower === 'y' && (k.shift || key === 'Y')) return send(m, { k: 'option', key: 'showTree', value: !m.props.options.showTree })
685  if (k.ctrl && lower === 's') {
686    const b = activeBuf(m)
687    if (b?.dirty) save(m, b)
688    return
689  }
690  if (k.ctrl && lower === 'f') return toggleFind(m)
691  if (k.ctrl && lower === 'p') {
692    m.focus = 'search'
693    return
694  }
695  if (k.ctrl && lower === 'w') return m.props.active ? requestClose(m, m.props.active) : undefined
696  if (k.ctrl && (key === 'pagedown' || key === 'pageup')) return cycleTab(m, key === 'pagedown' ? 1 : -1)
697  if ((key === 'f10' && k.shift) || lower === 'menu' || lower === 'contextmenu') return keyMenu(m)
698  if (key === 'tab' && !(m.focus === 'editor' && m.props.options.tabIndents && activeBuf(m) && !activeBuf(m)!.readOnly && !isRendered(m, m.props.active ?? ''))) {
699    return cycleFocus(m, k.shift ? -1 : 1)
700  }
701  if (m.focus === 'search') return searchKey(m, k, key)
702  if (m.focus === 'find') return findKey(m, k, key)
703  if (m.focus === 'tree') return treeKey(m, k, key)
704  return editorKey(m, k, key)
705}
706
707function cycleFocus(m: Model, dir: number) {
708  const order: Focus[] = []
709  if (m.props.options.showTree) order.push('search', 'tree')
710  if (m.props.active) order.push('editor')
711  if (!order.length) return
712  const i = order.indexOf(m.focus === 'find' ? 'editor' : m.focus)
713  m.focus = order[(i + dir + order.length) % order.length]!
714}
715
716function cycleTab(m: Model, dir: number) {
717  const tabs = m.props.tabs
718  if (!tabs.length) return
719  const i = tabs.findIndex((t) => t.path === m.props.active)
720  const next = tabs[(i + dir + tabs.length) % tabs.length]
721  if (next) send(m, { k: 'activate', path: next.path })
722}
723
724/** Édite une ligne de saisie (recherche) ; vrai quand le texte a changé. */
725function lineEdit(state: { text: string; cx: number }, k: ClientKeyEvent, key: string): boolean | null {
726  const { text, cx } = state
727  if (key === 'left') state.cx = k.ctrl ? wordLeft(text, cx) : Math.max(0, cx - 1)
728  else if (key === 'right') state.cx = k.ctrl ? wordRight(text, cx) : Math.min(text.length, cx + 1)
729  else if (key === 'home') state.cx = 0
730  else if (key === 'end') state.cx = text.length
731  else if (key === 'backspace') {
732    if (!cx) return false
733    const from = k.ctrl ? wordLeft(text, cx) : cx - 1
734    state.text = text.slice(0, from) + text.slice(cx)
735    state.cx = from
736    return true
737  } else if (key === 'delete') {
738    if (cx >= text.length) return false
739    state.text = text.slice(0, cx) + text.slice(cx + 1)
740    return true
741  } else if (k.ctrl && key.toLowerCase() === 'u') {
742    state.text = ''
743    state.cx = 0
744    return true
745  } else if (isText(k, key)) {
746    const add = key.replace(/[\r\n\t]+/g, ' ')
747    state.text = text.slice(0, cx) + add + text.slice(cx)
748    state.cx = cx + add.length
749    return true
750  } else return null
751  return false
752}
753
754function searchKey(m: Model, k: ClientKeyEvent, key: string) {
755  const s = m.search
756  const results = s.text.trim() ? m.props.find.results : []
757  if (key === 'down') {
758    if (!results.length) m.focus = 'tree'
759    else s.sel = Math.min(results.length - 1, s.sel + 1)
760    return
761  }
762  if (key === 'up') {
763    s.sel = Math.max(0, s.sel - 1)
764    return
765  }
766  if (key === 'pagedown' || key === 'pageup') {
767    const page = Math.max(1, (m.layout?.listH ?? 10) - 1)
768    s.sel = clamp(s.sel + (key === 'pagedown' ? page : -page), 0, Math.max(0, results.length - 1))
769    return
770  }
771  if (key === 'return') {
772    const path = results[s.sel] ?? results[0]
773    if (path) openResult(m, path, true)
774    return
775  }
776  const changed = lineEdit(s, k, key)
777  if (changed === null) return false
778  if (changed) {
779    s.sel = 0
780    s.top = 0
781    send(m, { k: 'find', query: s.text })
782  }
783}
784
785function openResult(m: Model, path: string, focusEditor: boolean) {
786  if (path.endsWith('/')) {
787    const dir = path.slice(0, -1)
788    send(m, { k: 'expand', paths: [...ancestors(dir), dir] })
789    m.search = { text: '', cx: 0, sel: 0, top: 0 }
790    m.pendingReveal = dir
791    m.focus = 'tree'
792    return
793  }
794  send(m, { k: 'open', path, preview: !focusEditor })
795  send(m, { k: 'expand', paths: ancestors(path) })
796  m.treeSel = path
797  if (focusEditor) m.focus = 'editor'
798}
799
800function treeKey(m: Model, k: ClientKeyEvent, key: string) {
801  const rows = m.rows
802  if (!rows.length) return
803  let i = rows.findIndex((r) => r.path === m.treeSel && r.kind !== 'x' && r.kind !== 'l')
804  const row = rows[i]
805  const page = Math.max(1, (m.layout?.listH ?? 10) - 1)
806  const select = (j: number) => {
807    i = clamp(j, 0, rows.length - 1)
808    m.treeSel = rows[i]!.path
809    showTreeRow(m, i)
810  }
811  if (key === 'down') return select(i + 1)
812  if (key === 'up') return select(i < 0 ? 0 : i - 1)
813  if (key === 'pagedown') return select(i + page)
814  if (key === 'pageup') return select(i - page)
815  if (key === 'home') return select(0)
816  if (key === 'end') return select(rows.length - 1)
817  if (!row) {
818    if (isText(k, key)) return typeIntoSearch(m, key)
819    return select(0)
820  }
821  if (key === 'right') {
822    if (row.kind === 'd') return send(m, { k: 'toggle', path: row.path })
823    if (row.kind === 'o') return select(i + 1)
824    return
825  }
826  if (key === 'left') {
827    if (row.kind === 'o') return send(m, { k: 'toggle', path: row.path })
828    const parent = parentOf(row.path)
829    const j = rows.findIndex((r) => r.path === parent && r.kind === 'o')
830    if (j >= 0) select(j)
831    return
832  }
833  if (key === 'return' || key === ' ') {
834    if (row.kind === 'd' || row.kind === 'o') return send(m, { k: 'toggle', path: row.path })
835    if (row.kind === 'f') {
836      send(m, { k: 'open', path: row.path, preview: key === ' ' })
837      if (key === 'return') m.focus = 'editor'
838    }
839    return
840  }
841  if (isText(k, key)) return typeIntoSearch(m, key)
842  return false
843}
844
845function typeIntoSearch(m: Model, key: string) {
846  m.focus = 'search'
847  m.search = { text: key, cx: key.length, sel: 0, top: 0 }
848  send(m, { k: 'find', query: key })
849}
850
851function showTreeRow(m: Model, i: number) {
852  const h = m.layout?.listH ?? 10
853  if (i < m.treeTop) m.treeTop = i
854  if (i >= m.treeTop + h) m.treeTop = i - h + 1
855}
856
857function editorKey(m: Model, k: ClientKeyEvent, key: string): boolean | void {
858  const b = activeBuf(m)
859  const L = m.layout
860  if (!b || !L) return false
861  const path = b.path
862  if (isRendered(m, path)) {
863    const blocks = blocksOf(b)
864    const top = m.mdTop.get(path) ?? 0
865    const by = key === 'down' ? 1 : key === 'up' ? -1 : key === 'pagedown' ? 5 : key === 'pageup' ? -5 : key === 'home' ? -top : key === 'end' ? blocks.length : 0
866    if (!by) return false
867    m.mdTop.set(path, clamp(top + by, 0, Math.max(0, blocks.length - 1)))
868    return
869  }
870  if (b.large) {
871    const by = key === 'down' ? 1 : key === 'up' ? -1 : key === 'pagedown' ? L.edH - 1 : key === 'pageup' ? 1 - L.edH : key === 'home' ? -b.top : key === 'end' ? b.large.count : 0
872    if (!by) return false
873    scrollEditor(m, b, by)
874    return
875  }
876  const line = b.lines[b.cy] ?? ''
877  const lower = key.toLowerCase()
878  const tabSize = m.props.options.tabSize
879  const unit = unitOf(m, b)
880  if (k.ctrl && lower === 'z') return k.shift ? undo(m, b, true) : undo(m, b, false)
881  if (k.ctrl && lower === 'y') return undo(m, b, true)
882  const move = (fn: () => void) => {
883    fn()
884    b.want = -1
885    showCursor(m, b)
886  }
887  switch (key) {
888    case 'left':
889      return move(() => {
890        if (k.ctrl || k.meta) b.cx = wordLeft(line, b.cx)
891        else if (b.cx > 0) b.cx -= /[\uDC00-\uDFFF]/.test(line[b.cx - 1] ?? '') ? 2 : 1
892        else if (b.cy > 0) {
893          b.cy -= 1
894          b.cx = (b.lines[b.cy] ?? '').length
895        }
896      })
897    case 'right':
898      return move(() => {
899        if (k.ctrl || k.meta) b.cx = wordRight(line, b.cx)
900        else if (b.cx < line.length) b.cx += /[\uD800-\uDBFF]/.test(line[b.cx] ?? '') ? 2 : 1
901        else if (b.cy < b.lines.length - 1) {
902          b.cy += 1
903          b.cx = 0
904        }
905      })
906    case 'up':
907      return moveVertical(m, b, -1)
908    case 'down':
909      return moveVertical(m, b, 1)
910    case 'pageup':
911      b.top = Math.max(0, b.top - (L.edH - 1))
912      return moveVertical(m, b, -(L.edH - 1))
913    case 'pagedown':
914      b.top = Math.min(maxTop(m, b, L), b.top + (L.edH - 1))
915      return moveVertical(m, b, L.edH - 1)
916    case 'home':
917      return move(() => {
918        if (k.ctrl) b.cy = 0
919        b.cx = k.ctrl ? 0 : smartHome(line, b.cx)
920      })
921    case 'end':
922      return move(() => {
923        if (k.ctrl) b.cy = b.lines.length - 1
924        b.cx = (b.lines[b.cy] ?? '').length
925      })
926    case 'return':
927      return edit(m, b, 'enter', (c) => newline(c, unit, langOf(path)))
928    case 'backspace':
929      return edit(m, b, 'delete', (c) => {
930        if (k.ctrl && c.cx > 0) {
931          const from = wordLeft(line, c.cx)
932          c.lines[c.cy] = line.slice(0, from) + line.slice(c.cx)
933          c.cx = from
934        } else deleteBack(c, indentStep(unit, tabSize))
935      })
936    case 'delete':
937      return edit(m, b, 'delete', (c) => deleteForward(c))
938    case 'tab':
939      return edit(m, b, 'indent', (c) => (k.shift ? outdent(c, unit, tabSize) : indent(c, unit, tabSize)))
940  }
941  if (isText(k, key)) return edit(m, b, key.length > 1 ? 'paste' : 'type', (c) => insertText(c, key))
942  return false
943}
944
945// ─── Recherche dans le fichier ──────────────────────────────────────────────
946
947function toggleFind(m: Model) {
948  const b = activeBuf(m)
949  if (!b || b.large) return
950  if (m.find.open && m.focus === 'find') {
951    m.find.open = false
952    m.focus = 'editor'
953    return
954  }
955  if (isRendered(m, b.path)) m.mdSource.add(b.path)
956  m.find.open = true
957  m.focus = 'find'
958  m.find.cx = m.find.text.length
959}
960
961function findKey(m: Model, k: ClientKeyEvent, key: string) {
962  const b = activeBuf(m)
963  if (!b) return
964  if (key === 'return' || key === 'down') return gotoMatch(m, b, k.shift && key === 'return' ? -1 : 1)
965  if (key === 'up') return gotoMatch(m, b, -1)
966  const changed = lineEdit(m.find, k, key)
967  if (changed === null) return false
968  if (changed) {
969    const matches = matchesOf(m, b)
970    const at = matches.findIndex(([y, x]) => y > b.cy || (y === b.cy && x >= b.cx))
971    m.find.idx = at < 0 ? 0 : at
972    const hit = matches[m.find.idx]
973    if (hit) revealMatch(m, b, hit)
974  }
975}
976
977function gotoMatch(m: Model, b: Buf, dir: number) {
978  const matches = matchesOf(m, b)
979  if (!matches.length) return
980  m.find.idx = (m.find.idx + dir + matches.length) % matches.length
981  revealMatch(m, b, matches[m.find.idx]!)
982}
983
984function revealMatch(m: Model, b: Buf, hit: [number, number, number]) {
985  b.cy = hit[0]
986  b.cx = hit[1]
987  b.want = -1
988  showCursor(m, b)
989}
990
991// ─── Fermeture d'onglet ─────────────────────────────────────────────────────
992
993function requestClose(m: Model, path: string) {
994  const b = m.bufs.get(path)
995  if ((b && b.dirty) || m.props.dirty.includes(path)) {
996    m.confirm = path
997    return
998  }
999  send(m, { k: 'close', path })
1000}
1001
1002function confirmKey(m: Model, key: string) {
1003  const lower = key.toLowerCase()
1004  if (lower === 'return' || lower === 'e' || lower === 'o') return answerConfirm(m, 'save')
1005  if (lower === 'n') return answerConfirm(m, 'discard')
1006  return answerConfirm(m, 'cancel')
1007}
1008
1009function answerConfirm(m: Model, choice: 'save' | 'discard' | 'cancel') {
1010  const path = m.confirm
1011  m.confirm = null
1012  if (!path || choice === 'cancel') return
1013  const b = m.bufs.get(path)
1014  if (choice === 'save' && b) save(m, b)
1015  if (b) b.dirty = false
1016  send(m, { k: 'close', path })
1017}
1018
1019// ─── Menus ──────────────────────────────────────────────────────────────────
1020
1021const selectable = (it: MenuItem | undefined) => Boolean(it && !it.isSeparator && !it.isDisabled)
1022
1023function firstSel(items: MenuItem[]) {
1024  const i = items.findIndex(selectable)
1025  return i < 0 ? 0 : i
1026}
1027
1028function openMenu(m: Model, kind: Menu['kind'], path: string, isDir: boolean, x: number, y: number) {
1029  const L = m.layout
1030  const p = m.props
1031  const tab = p.tabs.find((t) => t.path === path)
1032  const items = kind === 'view' ? viewMenu(p.options) : kind === 'tab' ? tabMenu(Boolean(tab?.isPreview), p.apps, p.os) : entryMenu(isDir, p.apps, p.os)
1033  const w = menuWidth(items)
1034  const W = L?.W ?? 80
1035  const H = L?.H ?? 24
1036  const h = items.length + 2
1037  m.menu = {
1038    kind,
1039    path,
1040    isDir,
1041    items,
1042    w,
1043    x: clamp(x, 0, Math.max(0, W - w - 2)),
1044    y: clamp(y, 1, Math.max(1, H - h)),
1045    sel: firstSel(items),
1046    sub: null,
1047  }
1048}
1049
1050function keyMenu(m: Model) {
1051  if (m.focus === 'tree') {
1052    const i = m.rows.findIndex((r) => r.path === m.treeSel && (r.kind === 'f' || r.kind === 'd' || r.kind === 'o'))
1053    const row = m.rows[i]
1054    if (row) return openMenu(m, 'entry', row.path, row.kind !== 'f', 2 + row.depth * 2, m.layout ? m.layout.listY + i - m.treeTop + 1 : 3)
1055  }
1056  if (m.props.active) return openMenu(m, 'tab', m.props.active, false, (m.layout?.cx0 ?? 0) + 2, 1)
1057  return openMenu(m, 'view', '', false, (m.layout?.W ?? 80) - 40, 1)
1058}
1059
1060function openSub(m: Model) {
1061  const menu = m.menu
1062  if (!menu) return
1063  const it = menu.items[menu.sel]
1064  if (!it?.sub) return
1065  const W = m.layout?.W ?? 80
1066  const w = menuWidth(it.sub)
1067  const right = menu.x + menu.w + 2
1068  const x = right + w + 2 <= W ? right - 1 : Math.max(0, menu.x - w - 1)
1069  menu.sub = { items: it.sub, x, y: clamp(menu.y + menu.sel, 1, Math.max(1, (m.layout?.H ?? 24) - it.sub.length - 2)), w, sel: firstSel(it.sub) }
1070}
1071
1072function menuKey(m: Model, key: string) {
1073  const menu = m.menu!
1074  const level: SubMenu = menu.sub ?? menu
1075  const step = (dir: number) => {
1076    const n = level.items.length
1077    for (let j = 1; j <= n; j++) {
1078      const i = (level.sel + dir * j + n * 2) % n
1079      if (selectable(level.items[i])) {
1080        level.sel = i
1081        return
1082      }
1083    }
1084  }
1085  if (key === 'down') return step(1)
1086  if (key === 'up') return step(-1)
1087  if (key === 'right' && !menu.sub) return openSub(m)
1088  if (key === 'left') {
1089    if (menu.sub) menu.sub = null
1090    else m.menu = null
1091    return
1092  }
1093  if (key === 'return' || key === ' ') return chooseItem(m, level.items[level.sel], level === menu)
1094  if (key === 'tab') {
1095    m.menu = null
1096    return
1097  }
1098  return false
1099}
1100
1101function chooseItem(m: Model, it: MenuItem | undefined, isTop: boolean) {
1102  const menu = m.menu
1103  if (!menu || !it || !selectable(it)) return
1104  if (it.sub && isTop) {
1105    menu.sel = menu.items.indexOf(it)
1106    return openSub(m)
1107  }
1108  m.menu = null
1109  const { path, isDir } = menu
1110  const id = it.id
1111  if (id.startsWith('opt:')) {
1112    const key = id.slice(4) as keyof View['options']
1113    return send(m, { k: 'option', key, value: !m.props.options[key] })
1114  }
1115  if (id.startsWith('tabSize:')) return send(m, { k: 'option', key: 'tabSize', value: Number(id.slice(8)) })
1116  if (id === 'find') {
1117    if (m.props.active !== path) send(m, { k: 'activate', path })
1118    return toggleFind(m)
1119  }
1120  if (id === 'reload') {
1121    const b = m.bufs.get(path)
1122    if (b) {
1123      b.dirty = false
1124      b.conflict = false
1125      b.rev = -1
1126    }
1127    return send(m, { k: 'reload', path })
1128  }
1129  if (id === 'keepOpen') return send(m, { k: 'pin', path })
1130  if (id === 'close') return requestClose(m, path)
1131  send(m, { k: 'act', id, path, isDir })
1132}
1133
1134// ─── Souris ─────────────────────────────────────────────────────────────────
1135
1136function hit(m: Model, x: number, y: number): Zone | undefined {
1137  for (let i = m.zones.length - 1; i >= 0; i--) {
1138    const z = m.zones[i]!
1139    if (x >= z.x0 && x < z.x1 && y >= z.y0 && y < z.y1) return z
1140  }
1141  return undefined
1142}
1143
1144function onPointer(m: Model, p: ClientPointerEvent): boolean | void {
1145  if (p.type === 'move') {
1146    if (m.drag) return dragTo(m, p.x, p.y)
1147    if (!m.menu) return false
1148    const z = hit(m, p.x, p.y)
1149    if (z?.id !== 'menuItem') return false
1150    const { level, index } = z.data as { level: number; index: number }
1151    const target = level === 0 ? m.menu : m.menu.sub
1152    if (!target || index < 0 || target.sel === index || !selectable(target.items[index])) return false
1153    target.sel = index
1154    if (level === 0 && m.menu.sub && m.menu.items[index]?.sub !== m.menu.sub.items) m.menu.sub = null
1155    return true
1156  }
1157  if (p.type === 'up') {
1158    if (!m.drag) return false
1159    if (m.drag.kind === 'split' && m.sideW !== null) send(m, { k: 'option', key: 'sidebarWidth', value: m.sideW })
1160    m.drag = null
1161    return true
1162  }
1163  if (p.type !== 'down') return false
1164  const z = hit(m, p.x, p.y)
1165  const t = now()
1166  const isDouble = Boolean(m.lastClick && t - m.lastClick.t < 450 && Math.abs(m.lastClick.x - p.x) <= 1 && m.lastClick.y === p.y)
1167  m.lastClick = { t, x: p.x, y: p.y }
1168  if (m.menu && z?.id !== 'menuItem') {
1169    m.menu = null
1170    return true
1171  }
1172  if (p.button === 'right') return rightClick(m, z, p)
1173  if (p.button === 'middle') {
1174    if (z?.id === 'tab' || z?.id === 'tabx') requestClose(m, z.data as string)
1175    return
1176  }
1177  return leftClick(m, z, p, isDouble)
1178}
1179
1180function rightClick(m: Model, z: Zone | undefined, p: ClientPointerEvent) {
1181  if (!z) return false
1182  if (z.id === 'row') {
1183    const row = m.rows[z.data as number]
1184    if (!row || row.kind === 'x' || row.kind === 'l') return false
1185    m.treeSel = row.path
1186    m.focus = 'tree'
1187    return openMenu(m, 'entry', row.path, row.kind !== 'f', p.x, p.y + 1)
1188  }
1189  if (z.id === 'result') {
1190    const path = z.data as string
1191    const isDir = path.endsWith('/')
1192    return openMenu(m, 'entry', isDir ? path.slice(0, -1) : path, isDir, p.x, p.y + 1)
1193  }
1194  if (z.id === 'tab' || z.id === 'tabx') return openMenu(m, 'tab', z.data as string, false, p.x, p.y + 1)
1195  if ((z.id === 'edit' || z.id === 'md' || z.id === 'crumbFile') && m.props.active) return openMenu(m, 'tab', m.props.active, false, p.x, p.y + 1)
1196  return false
1197}
1198
1199function leftClick(m: Model, z: Zone | undefined, p: ClientPointerEvent, isDouble: boolean): boolean | void {
1200  if (!z) return false
types/index.d.ts 183 lines
1// Contrat du mod files-board : l'état de session (`$.state`) et les données qui circulent
2// entre le module des hooks (register.tsx, qui seul parle au moteur) et la région
3// interactive (browser.tsx, qui dessine et lit le clavier et la souris).
4
5export type Os = 'windows' | 'mac' | 'linux'
6
7/** Les options du menu ⋮, gardées d'une session à l'autre (`$.store`). */
8export type Options = {
9  /** Afficher l'arborescence de fichiers (Ctrl+Maj+Y). */
10  showTree: boolean
11  /** Onglets d'aperçu : un fichier ouvert d'un simple clic remplace l'aperçu précédent. */
12  previewTabs: boolean
13  /** Enregistrement automatique, une seconde après la dernière frappe. */
14  autoSave: boolean
15  /** La touche Tab indente ; sinon elle passe d'une zone à l'autre. */
16  tabIndents: boolean
17  /** Retour à la ligne automatique ; sinon défilement horizontal. */
18  wordWrap: boolean
19  /** Repères d'indentation (│) dans l'indentation. */
20  indentGuides: boolean
21  /** Afficher les espaces (·) et les tabulations (→). */
22  showWhitespace: boolean
23  /** Défilement au-delà de la fin : la dernière ligne peut monter en haut de la zone. */
24  scrollBeyondLastLine: boolean
25  /** Taille de tabulation : 2, 4 ou 8. */
26  tabSize: number
27  /** Largeur de l'arborescence, en colonnes ; 0 pour la largeur automatique. */
28  sidebarWidth: number
29}
30export type OptionKey = keyof Options
31
32export type EntryKind = 'file' | 'dir'
33export type Entry = { name: string; kind: EntryKind }
34/** Le contenu d'un dossier tel que lu, plafonné ; `hidden` compte les entrées non gardées. */
35export type Listing = { entries: Entry[]; hidden: number; error: string | null }
36
37/** Un onglet ouvert ; `path` est relatif à la racine, séparé par `/`. */
38export type Tab = { path: string; isPreview: boolean }
39
40/**
41 * `ready` : texte complet, modifiable. `large` : trop gros pour passer à la région, lecture
42 * seule par fenêtres de lignes. `binary`, `error` : rien à afficher.
43 */
44export type DocState = 'loading' | 'ready' | 'large' | 'binary' | 'error'
45
46export type Doc = {
47  path: string
48  state: DocState
49  error: string | null
50  /** Le texte du disque, fins de ligne ramenées à `\n` ; vide hors de `ready`. */
51  text: string
52  /** Modifications non enregistrées, gardées si le panneau se ferme. */
53  draft: string | null
54  eol: '\n' | '\r\n'
55  /** Monte à chaque relecture ou enregistrement. */
56  rev: number
57  /**
58   * `disk`, `claude` (relu parce que Claude venait de modifier le fichier), ou
59   * `save:<instance>:<seq>` après un enregistrement venu de la région.
60   */
61  origin: string
62  /** Le fichier a changé sur le disque alors qu'un brouillon existe. */
63  changedOnDisk: boolean
64  lineCount: number
65  /** Fichier volumineux : la fenêtre de lignes envoyée, à partir de la ligne `from` (0). */
66  window: { from: number; text: string } | null
67  size: number
68  mtimeMs: number
69}
70
71/** Recherche de fichiers : chemins relatifs, ceux des dossiers finis par `/`. */
72export type Find = { query: string; results: string[]; total: number; isLoading: boolean }
73
74/** Dernière opération de la région traitée, par instance de la région. */
75export type Seen = { inst: string; seq: number }
76
77/** Dernier coup de molette sur le panneau, relayé à la région (`n` monte à chaque coup). */
78export type Wheel = { n: number; by: number; column: number; row: number }
79
80/** Une application de « Ouvrir dans ». */
81export type App = { id: string; label: string }
82
83export type Platform = { os: Os; sep: '\\' | '/'; apps: App[]; isReady: boolean }
84
85/** Demande à la région de montrer `path` dans l'arborescence (`n` monte à chaque demande). */
86export type Reveal = { path: string; n: number }
87
88/** Ce que la région reçoit du document actif. */
89export type DocView = {
90  path: string
91  state: DocState
92  error: string | null
93  /** Le brouillon s'il existe, sinon le texte du disque. */
94  text: string
95  isDirty: boolean
96  rev: number
97  origin: string
98  changedOnDisk: boolean
99  lineCount: number
100  window: { from: number; text: string } | null
101  size: number
102  eol: '\n' | '\r\n'
103}
104
105/** Les données de la région (props du `Client`). */
106export type View = {
107  root: string
108  rootName: string
109  sep: '\\' | '/'
110  os: Os
111  options: Options
112  /** Lignes visibles de l'arborescence, codées par `encodeRow` (model.ts). */
113  rows: string[]
114  /** Lignes non envoyées, au-delà du plafond. */
115  rowsHidden: number
116  find: Find
117  tabs: Tab[]
118  active: string | null
119  /** Onglets qui ont un brouillon. */
120  dirty: string[]
121  /** Fichiers que Claude a modifiés (Edit, Write, NotebookEdit) depuis le dernier message, relatifs à la racine. */
122  touched: string[]
123  doc: DocView | null
124  ack: Seen
125  wheel: Wheel
126  reveal: Reveal
127  apps: App[]
128  isMaximized: boolean
129  isFullscreen: boolean
130  /** Taille du corps du panneau, en repli tant que la région n'est pas mesurée. */
131  columns: number
132  lines: number
133}
134
135/** Ce que la région demande au module des hooks. */
136export type Op =
137  | { k: 'toggle'; path: string }
138  | { k: 'expand'; paths: string[] }
139  | { k: 'open'; path: string; preview: boolean }
140  | { k: 'activate'; path: string }
141  | { k: 'pin'; path: string }
142  | { k: 'close'; path: string }
143  | { k: 'save'; path: string; text: string }
144  | { k: 'draft'; path: string; text: string | null }
145  | { k: 'reload'; path: string }
146  | { k: 'keep'; path: string }
147  | { k: 'window'; path: string; from: number }
148  | { k: 'find'; query: string }
149  | { k: 'option'; key: OptionKey; value: boolean | number }
150  | { k: 'act'; id: string; path: string; isDir: boolean }
151  | { k: 'pane'; what: 'close' | 'maximize' }
152  | { k: 'refresh' }
153
154export type Sent = Op & { seq: number }
155
156/** Un envoi de la région : toutes ses opérations pas encore accusées. */
157export type Post = { inst: string; ops: Sent[] }
158
159declare module 'claude-code' {
160  interface PluginState {
161    'files-board': {
162      /** Dossier parcouru, absolu ; null pour celui de la session. */
163      root: string | null
164      /** Dossiers dépliés, relatifs à la racine. */
165      expanded: string[]
166      /** Contenu lu de chaque dossier déplié ('' pour la racine). */
167      listings: Record<string, Listing>
168      tabs: Tab[]
169      active: string | null
170      docs: Record<string, Doc>
171      find: Find
172      options: Options
173      seen: Seen
174      wheel: Wheel
175      reveal: Reveal
176      isMaximized: boolean
177      platform: Platform
178      /** Fichiers que Claude a modifiés depuis le dernier message de la personne, absolus, les plus récents en dernier. */
179      touched: string[]
180    }
181  }
182}
183