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

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.
/files répond que le navigateur est réservé au terminal : l'app desktop a déjà le sien.◆, ses dossiers ◇, jusqu'à votre message suivant.Version actuelle : 0.3.3. Historique des versions : CHANGELOG.md.
Le mod est distribué par la marketplace mathis-plugins (dépôt GitHub matwi5/mathis-plugins, source dans plugins/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
Client (région interactive) : version 2.1.289 ou plus récente.CLAUDE_CODE_NO_FLICKER=0, tmux), la souris n'atteint pas le navigateur.git (recherche de fichiers plus rapide, .gitignore respecté), code ou cursor dans le PATH (« Ouvrir dans »)./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.session-tools) fait la même bascule, sans ligne dans la conversation.≡ 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
≡ 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.●. 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. ».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.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]).Sous 70 colonnes, le panneau montre l'arborescence ou le fichier, pas les deux : ouvrir un fichier passe au fichier, Tab revient à l'arborescence.
Menu ⋮ (gardé d'une session à l'autre) :
| Option | Effet |
|---|---|
| Afficher l'arborescence de fichiers (Ctrl+Maj+Y) | Montre ou masque l'arborescence ; ≡ fait de même. |
| Onglets d'aperçu | Un 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 automatique | Enregistre 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 indente | Tab indente (Maj+Tab désindente) ; décochée, Tab passe de la recherche à l'arborescence puis à l'éditeur. |
| Retour à la ligne automatique | Replie les longues lignes aux espaces ; décochée, défilement horizontal qui suit le curseur. |
| Repères d'indentation | Un │ à chaque cran d'indentation. |
| Afficher les espaces | · pour un espace, → pour une tabulation. |
| Défilement au-delà de la fin | La dernière ligne peut monter en haut de l'éditeur. |
| Taille de tabulation › 2, 4, 8 | Largeur 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.
| Action | Dans le CLI |
|---|---|
| Joindre comme contexte | Insère @chemin au curseur du prompt (@dossier/ pour un dossier, entre guillemets s'il y a une espace). |
| Copier | Presse-papiers du terminal (outil du système ou OSC 52). |
| Ouvrir dans | Application par défaut ; Visual Studio Code et Cursor s'ils sont dans le PATH ; Bloc-notes (Windows) ou TextEdit (macOS). |
| Afficher dans l'Explorateur | explorer /select, sous Windows, open -R sous macOS, le dossier parent avec xdg-open ailleurs. |
| Recharger le fichier | Relit le disque et abandonne les modifications non enregistrées. |
Souris
│ : largeur de l'arborescence (gardée). Glisser une barre de défilement : défile.Clavier (après un clic dans le panneau)
| Touche | Effet |
|---|---|
| Ctrl+S | Enregistrer |
| Ctrl+F | Rechercher dans le fichier (encore une fois : fermer la barre) |
| Ctrl+P | Aller à la recherche de fichiers |
| Ctrl+W | Fermer l'onglet |
| Ctrl+PageSuiv / Ctrl+PagePréc | Onglet suivant / précédent |
| Ctrl+Maj+Y | Montrer ou masquer l'arborescence |
| Maj+F10 | Menu de la ligne ou de l'onglet choisi |
| Tab / Maj+Tab | Indenter / désindenter, ou changer de zone (voir l'option) |
| Échap | Rendre le clavier au prompt |
{, [, (, : 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.n n'enregistre pas, une autre touche annule.| Commande | Effet |
|---|---|
/files-board:files | Ouvre le panneau. Connue dès le premier message. |
/files | La 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 close | Ferme le panneau. |
/files toggle | Ouvre 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ée | Emplacement | Portée |
|---|---|---|
Options du menu ⋮, largeur de l'arborescence | $.store du plugin | Toutes 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, menus | La région elle-même | Tant que le panneau est ouvert |
| Lecture | Quand |
|---|---|
| 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) :
| Quoi | Comment |
|---|---|
| Posée | Aprè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ée | Au 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. |
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
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.seen). Une recherche, un brouillon ou une fenêtre de lignes remplacent leur demande précédente encore en file.ui.scroll) et la lui relaie.claude plugin validate plugins/files-board
claude plugin test plugins/files-board
~) : depuis un dossier au chemin trop long, Windows refuse le dossier courant.world). Le moteur de test rend /w en C:\w sous Windows : la simulation ramène les chemins à /w.ui.advance dans les tests), pas avec l'horloge réelle.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.claude plugin test et un chargement claude -p) :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 ;/files demande $.ui.focus sur la région, sans garantie que le moteur l'accepte ;≡, ⌕, ⋮, ▐, ⚠) 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.hooks/register.tsx 919 lines1// 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}
919hooks/model.ts 1169 lines1// 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}
1169hooks/browser.tsx 1983 lines1// 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 falsetypes/index.d.ts 183 lines1// 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