@lamemind/loom-deck 0.69.0 → 0.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,468 @@
1
+ // T160 — l'albero della doc come dato del deck.
2
+ //
3
+ // Gemello di `src/inbox.ts` per forma e per ragione: la misura non la fa il
4
+ // deck, la fa `doc-metrics.sh` nel suo modo principale. Le soglie (split, merge,
5
+ // regroup, cap del TLDR) vivono in `lib-doc.sh` e in nessun altro posto —
6
+ // replicarne anche una sola qui darebbe due verdetti sullo stesso file, e la
7
+ // divergenza si vedrebbe solo il giorno in cui il pane dice `SPLIT` su un file
8
+ // che la skill considera in equilibrio.
9
+ //
10
+ // Lettore puro + spawn: nessun React, nessuna resa. È l'unico modo di provarlo
11
+ // senza pseudo-terminale.
12
+ //
13
+ // COSTO. Questa misura non è quella della coda inbox, benché lo script sia lo
14
+ // stesso: `--inbox` apre una cartella di una quindicina di file e costa
15
+ // millisecondi, il modo principale apre ogni `.md` sotto la docs-root, ne conta
16
+ // i caratteri, verifica le ancore del TLDR e aggrega per cartella — sul cappello
17
+ // loom-works, 90 file, 4,06 s di wall clock misurati. Da qui la cadenza propria
18
+ // e il divieto di entrare nel poll da 1,5 s (T153).
19
+ import { execFile } from 'node:child_process';
20
+ import { readFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { promisify } from 'node:util';
23
+ import { pluginScript } from './plugin-cache.js';
24
+ import { sanitize } from './width.js';
25
+ const execFileAsync = promisify(execFile);
26
+ export const DOC_METRICS_SCRIPT = 'scripts/docs/doc-metrics.sh';
27
+ /**
28
+ * Ogni 30 minuti, e SOLO mentre il modo doc è montato (P3 preflight).
29
+ *
30
+ * La cifra coincide con quella dello scan inbox ma la ragione è opposta. Lì
31
+ * l'intervallo è stretto perché il dato si muove a ogni checkpoint e costa
32
+ * niente misurarlo; qui è largo perché la topologia della doc si muove al ritmo
33
+ * di uno sweep e ogni giro costa quattro secondi di CPU. Sono due numeri uguali
34
+ * per caso: se uno dei due cambia, l'altro non lo segue.
35
+ *
36
+ * Il gate vero non è l'intervallo ma l'interruttore: il timer è acceso solo
37
+ * finché qualcuno guarda l'albero. Un periodico che girasse in modo task
38
+ * pagherebbe quei quattro secondi per nessuno.
39
+ */
40
+ export const DOC_SCAN_INTERVAL_MS = 30 * 60 * 1000;
41
+ /**
42
+ * I flag che `doc-metrics.sh` emette. Nove per file più uno per cartella —
43
+ * `REGROUP`, che nel report vive nella seconda tabella e qui entra nello stesso
44
+ * dominio perché una riga dell'albero può essere l'una o l'altra cosa.
45
+ *
46
+ * I nomi sono quelli dello script, LETTERALI: un rename qui li staccherebbe
47
+ * dalla misura senza che niente lo dica, e il pane mostrerebbe una colonna vuota
48
+ * su file che un flag ce l'hanno.
49
+ */
50
+ export const DOC_FLAGS = [
51
+ 'SPLIT',
52
+ 'MERGE?',
53
+ 'REGROUP',
54
+ 'TLDR>CAP',
55
+ 'NOTLDR',
56
+ 'TLDR-ORFANA',
57
+ 'ONLINE',
58
+ 'INBOX',
59
+ 'GEN',
60
+ 'CONFIG',
61
+ ];
62
+ const FLAG_SET = new Set(DOC_FLAGS);
63
+ /**
64
+ * L'ordine in cui i flag entrano nella colonna di riga, che NON è quello in cui
65
+ * lo script li emette.
66
+ *
67
+ * La colonna tiene tre short e il resto lo dice un `+` (P6): quale sia il resto
68
+ * lo decide quest'ordine. In testa i tre flag che chiamano un'operazione di
69
+ * `rebalance-doc` — sono la ragione per cui si guarda il pane — poi quelli del
70
+ * TLDR, che chiamano `write-tldr`, e in coda i descrittivi, che non chiamano
71
+ * niente. Così a cedere il posto è sempre l'informazione che non produce
72
+ * un'azione.
73
+ *
74
+ * `DOC_FLAGS` resta l'ordine dello script e questo è quello di lettura: tenerli
75
+ * separati costa una costante e toglie di mezzo la tentazione di riordinare
76
+ * l'elenco che deve combaciare con la misura.
77
+ */
78
+ const FLAG_ORDER = [
79
+ 'SPLIT',
80
+ 'MERGE?',
81
+ 'REGROUP',
82
+ 'TLDR>CAP',
83
+ 'NOTLDR',
84
+ 'TLDR-ORFANA',
85
+ 'ONLINE',
86
+ 'INBOX',
87
+ 'CONFIG',
88
+ 'GEN',
89
+ ];
90
+ /**
91
+ * Le short a tre lettere della colonna di riga.
92
+ *
93
+ * CHIESTE e non derivate con uno `slice(0,3)`, come le short dei modelli e
94
+ * quelle delle nature inbox: `MERGE?` e `TLDR>CAP` non hanno tre lettere iniziali
95
+ * che le distinguano (`MER` e `TLD` collidono con `MERGE`/`TLDR-ORFANA` al primo
96
+ * flag aggiunto), e una regola che sbaglia su due voci su dieci non è una regola.
97
+ * I nomi interi stanno nel blocco preview.
98
+ */
99
+ export const DOC_FLAG_SHORT = {
100
+ SPLIT: 'SPL',
101
+ 'MERGE?': 'MRG',
102
+ REGROUP: 'REG',
103
+ 'TLDR>CAP': 'CAP',
104
+ NOTLDR: 'NOT',
105
+ 'TLDR-ORFANA': 'ORF',
106
+ ONLINE: 'ONL',
107
+ INBOX: 'INB',
108
+ GEN: 'GEN',
109
+ CONFIG: 'CFG',
110
+ };
111
+ /** Quante short entrano nella colonna. Il quarto flag e tutti quelli dopo di lui
112
+ * diventano il `+` in coda: la cella resta a larghezza fissa, e su un pane largo
113
+ * la metà del terminale ogni colonna in più la paga la cella del nome, che è
114
+ * l'unica con cui si riconosce un file. */
115
+ const FLAG_SLOTS = 3;
116
+ /** Larghezza COSTANTE della colonna: tre short da 3, due separatori, una cella
117
+ * per il `+` dell'overflow. Il dominio è chiuso e le short sono tutte larghe 3,
118
+ * quindi misurarla a ogni render calcolerebbe un numero già noto. */
119
+ export const DOC_FLAG_W = FLAG_SLOTS * 3 + (FLAG_SLOTS - 1) + 1;
120
+ /**
121
+ * La cella dei flag di una riga, già a larghezza `DOC_FLAG_W`.
122
+ *
123
+ * Tutto ASCII per costruzione (le short sono lettere maiuscole, il riempimento
124
+ * spazi), quindi `padEnd` conta esattamente le colonne disegnate e non serve la
125
+ * contabilità di `width.ts` — che esiste per i glifi su cui le due misure
126
+ * divergono, e qui non ce n'è nessuno.
127
+ */
128
+ export function flagCell(flags) {
129
+ const ordered = FLAG_ORDER.filter((f) => flags.includes(f));
130
+ const shown = ordered.slice(0, FLAG_SLOTS).map((f) => DOC_FLAG_SHORT[f]);
131
+ const more = ordered.length > FLAG_SLOTS ? '+' : '';
132
+ return (shown.join(' ').padEnd(FLAG_SLOTS * 3 + (FLAG_SLOTS - 1)) + more).padEnd(DOC_FLAG_W);
133
+ }
134
+ /** I nomi interi, nell'ordine di lettura: è ciò che il blocco preview mostra al
135
+ * posto delle short. Vuoto → nessun flag, e chi lo rende lo dice a parole. */
136
+ export function flagNames(flags) {
137
+ return FLAG_ORDER.filter((f) => flags.includes(f));
138
+ }
139
+ /** Colonna FLAGS → i flag noti. `-` è il vuoto dello script; un token che il
140
+ * deck non conosce si scarta invece di finire in colonna, perché la sua short
141
+ * non esisterebbe e la cella uscirebbe di larghezza. */
142
+ function parseFlags(cell) {
143
+ if (!cell || cell === '-')
144
+ return [];
145
+ return cell.split(' ').filter((t) => FLAG_SET.has(t));
146
+ }
147
+ function num(cell) {
148
+ const n = Number(cell);
149
+ return Number.isFinite(n) ? n : 0;
150
+ }
151
+ /**
152
+ * Le DUE tabelle del modo principale, dallo stesso stream.
153
+ *
154
+ * PATH · CHAR · TLDR · FLAGS (i file, char decrescente)
155
+ * <riga vuota>
156
+ * DIR · FILES · CHAR · FLAGS (le cartelle, char decrescente)
157
+ *
158
+ * Il discriminante è l'header, non la posizione: un parser che contasse le righe
159
+ * si romperebbe al primo `# soglie:` in testa (che compare sotto override) o a
160
+ * una riga vuota in più. Le righe fuori da una tabella aperta si ignorano.
161
+ */
162
+ export function parseDocTsv(stdout) {
163
+ const files = [];
164
+ const dirs = [];
165
+ let hasDirs = false;
166
+ let table = 'none';
167
+ for (const line of stdout.split('\n')) {
168
+ if (!line.trim())
169
+ continue;
170
+ if (line.startsWith('#'))
171
+ continue;
172
+ const c = line.split('\t');
173
+ if (c[0] === 'PATH') {
174
+ table = 'files';
175
+ continue;
176
+ }
177
+ if (c[0] === 'DIR') {
178
+ table = 'dirs';
179
+ hasDirs = true;
180
+ continue;
181
+ }
182
+ if (table === 'files' && c.length >= 4) {
183
+ files.push({ path: c[0], chars: num(c[1]), tldr: num(c[2]), flags: parseFlags(c[3]) });
184
+ }
185
+ else if (table === 'dirs' && c.length >= 4) {
186
+ dirs.push({ path: c[0], files: num(c[1]), chars: num(c[2]), flags: parseFlags(c[3]) });
187
+ }
188
+ }
189
+ return { files, dirs, hasDirs };
190
+ }
191
+ function parentOf(path) {
192
+ const at = path.lastIndexOf('/');
193
+ return at <= 0 ? '' : path.slice(0, at);
194
+ }
195
+ function lastSegment(path) {
196
+ const at = path.lastIndexOf('/');
197
+ return at < 0 ? path : path.slice(at + 1);
198
+ }
199
+ function push(m, k, v) {
200
+ const at = m.get(k);
201
+ if (at)
202
+ at.push(v);
203
+ else
204
+ m.set(k, [v]);
205
+ }
206
+ /**
207
+ * L'albero, dalle due tabelle.
208
+ *
209
+ * Due sorgenti di cartelle, ed è deliberato: quelle della tabella (col loro
210
+ * conteggio e il loro flag) più quelle DEDOTTE dai path — una cartella
211
+ * intermedia che non contiene `.md` propri non compare nella misura, perché lo
212
+ * script aggrega per `dirname` dei file, ma l'albero la deve attraversare o le
213
+ * sue figlie resterebbero irraggiungibili. Una dedotta nasce a zero file, zero
214
+ * char e nessun flag: è una riga di struttura, non una misura mancante.
215
+ */
216
+ function assemble(data) {
217
+ const dirs = new Map();
218
+ for (const d of data.dirs)
219
+ dirs.set(d.path, d);
220
+ const files = new Map();
221
+ for (const f of data.files)
222
+ push(files, parentOf(f.path), f);
223
+ // Chiusura verso l'alto: ogni antenato di una cartella nota è una cartella.
224
+ for (const p of [...dirs.keys(), ...files.keys()]) {
225
+ let cur = p;
226
+ while (cur) {
227
+ if (!dirs.has(cur))
228
+ dirs.set(cur, { path: cur, files: 0, chars: 0, flags: [] });
229
+ cur = parentOf(cur);
230
+ }
231
+ }
232
+ const subdirs = new Map();
233
+ const roots = [];
234
+ for (const p of dirs.keys()) {
235
+ const parent = parentOf(p);
236
+ if (parent && dirs.has(parent))
237
+ push(subdirs, parent, p);
238
+ else
239
+ roots.push(p);
240
+ }
241
+ const byName = (a, b) => (lastSegment(a) < lastSegment(b) ? -1 : 1);
242
+ for (const list of subdirs.values())
243
+ list.sort(byName);
244
+ for (const list of files.values())
245
+ list.sort((a, b) => (a.path < b.path ? -1 : 1));
246
+ roots.sort(byName);
247
+ return { dirs, subdirs, files, roots };
248
+ }
249
+ /**
250
+ * Le righe dell'albero, in pre-ordine: una cartella, poi le sue sottocartelle,
251
+ * poi i suoi file.
252
+ *
253
+ * Cartelle PRIMA dei file a ogni livello, ed è l'ordine dei file manager, non
254
+ * una preferenza: un elenco misto alfabetico spezza i rami: una sottocartella
255
+ * finisce fra due file e chi scorre non vede più dove comincia il livello sotto.
256
+ *
257
+ * Nessun espandi/comprimi (P7): l'albero scorre a finestra come le altre liste,
258
+ * e la domanda «su cosa lancio rebalance» la risolvono le viste per flag, che
259
+ * sono piatte.
260
+ */
261
+ export function buildDocTree(data) {
262
+ const tree = assemble(data);
263
+ const out = [];
264
+ const walk = (dirPath, depth) => {
265
+ const d = tree.dirs.get(dirPath);
266
+ out.push({
267
+ kind: 'dir',
268
+ path: dirPath,
269
+ name: lastSegment(dirPath),
270
+ depth,
271
+ chars: d.chars,
272
+ tldr: 0,
273
+ files: d.files,
274
+ flags: d.flags,
275
+ });
276
+ for (const sub of tree.subdirs.get(dirPath) ?? [])
277
+ walk(sub, depth + 1);
278
+ for (const f of tree.files.get(dirPath) ?? []) {
279
+ out.push({
280
+ kind: 'file',
281
+ path: f.path,
282
+ name: lastSegment(f.path),
283
+ depth: depth + 1,
284
+ chars: f.chars,
285
+ tldr: f.tldr,
286
+ files: 0,
287
+ flags: f.flags,
288
+ });
289
+ }
290
+ };
291
+ for (const root of tree.roots)
292
+ walk(root, 0);
293
+ return out;
294
+ }
295
+ /**
296
+ * La radice dell'albero: la cartella che non ha padre fra le misurate.
297
+ *
298
+ * Serve alle viste piatte, che mostrano il path invece dell'ultimo segmento e
299
+ * senza questo prefisso ripeterebbero `runtime/` su ogni riga — cioè
300
+ * spenderebbero colonne per dire ciò che vale per tutte. Con più di una radice
301
+ * (misura di un sottoalbero, o due docs-root nello stesso stream) non si taglia
302
+ * niente: il prefisso lì distingue davvero le righe.
303
+ */
304
+ export function docRoot(data) {
305
+ const tree = assemble(data);
306
+ return tree.roots.length === 1 ? tree.roots[0] : '';
307
+ }
308
+ function stripRoot(path, root) {
309
+ return root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
310
+ }
311
+ /**
312
+ * Le righe PIATTE dei soli elementi che portano uno dei flag chiesti — file e
313
+ * cartelle insieme, senza le cartelle antenate.
314
+ *
315
+ * L'assenza degli antenati non è una semplificazione: su un pane ad albero
316
+ * l'invariante «il contatore dell'header coincide col numero di righe mostrate»
317
+ * vale anche per le righe di struttura, e ogni cartella aggiunta solo per
318
+ * ospitare una figlia flaggata farebbe divergere il contatore dalla lista.
319
+ *
320
+ * Ordine: char decrescente, che è l'ordine in cui lo script le emette e quello
321
+ * in cui si guardano — il file più grosso è quello da spezzare per primo.
322
+ */
323
+ export function flatDocRows(data, flags) {
324
+ const root = docRoot(data);
325
+ const wanted = new Set(flags);
326
+ const hit = (f) => f.some((x) => wanted.has(x));
327
+ const out = [];
328
+ for (const d of data.dirs) {
329
+ if (!hit(d.flags))
330
+ continue;
331
+ out.push({
332
+ kind: 'dir',
333
+ path: d.path,
334
+ name: stripRoot(d.path, root),
335
+ depth: 0,
336
+ chars: d.chars,
337
+ tldr: 0,
338
+ files: d.files,
339
+ flags: d.flags,
340
+ });
341
+ }
342
+ for (const f of data.files) {
343
+ if (!hit(f.flags))
344
+ continue;
345
+ out.push({
346
+ kind: 'file',
347
+ path: f.path,
348
+ name: stripRoot(f.path, root),
349
+ depth: 0,
350
+ chars: f.chars,
351
+ tldr: f.tldr,
352
+ files: 0,
353
+ flags: f.flags,
354
+ });
355
+ }
356
+ return out.sort((a, b) => b.chars - a.chars);
357
+ }
358
+ /** Quanti elementi (file e cartelle) portano almeno uno dei flag: il numero che
359
+ * una voce del catalogo mostra, misurato sulla stessa fonte da cui esce la sua
360
+ * lista. */
361
+ export function countFlagged(data, flags) {
362
+ const wanted = new Set(flags);
363
+ const hit = (f) => f.some((x) => wanted.has(x));
364
+ return data.dirs.filter((d) => hit(d.flags)).length + data.files.filter((f) => hit(f.flags)).length;
365
+ }
366
+ /**
367
+ * T160 — il buco `{words}` del titolo di una sessione di rebalance: il path reso
368
+ * a parole.
369
+ *
370
+ * Gemello di `wrapWords`, e per la stessa ragione: l'alfabeto di `_sane_note`
371
+ * non ha né `/` né `.`, quindi un path lasciato cadere lì dentro esce con le
372
+ * parole saldate (`runtimereferencedoc-systemmd`) mentre la lista del deck, che
373
+ * legge la nota grezza dal sidecar, mostrerebbe il path intero — la stessa
374
+ * conversazione con due nomi, senza errore.
375
+ *
376
+ * Il path VERO resta a disposizione come buco `{target}`: il prompt della skill
377
+ * lo risolve contro la colonna `PATH` della misura, quindi lì deve arrivare
378
+ * intatto. Due buchi per lo stesso dato, perché le due destinazioni hanno due
379
+ * alfabeti.
380
+ */
381
+ export function docWords(path) {
382
+ return path
383
+ .replace(/\.md$/i, '')
384
+ .replace(/[./]+/g, ' ')
385
+ .trim();
386
+ }
387
+ /**
388
+ * Il testo di un file doc, per il detail fullscreen. `null` = illeggibile — il
389
+ * detail lo dice e tiene l'azione attiva, come quello dell'inbox: la skill
390
+ * risolve il bersaglio per path, non per il testo che il deck è riuscito a
391
+ * leggere.
392
+ *
393
+ * `sanitize` AL CONFINE DI CARICAMENTO, come `loadTaskFileText`: la catena
394
+ * dichiarata in `markdown.ts` è `sanitize → parseMarkdown → wrapWithOffsets`, e
395
+ * la prima non conserva la lunghezza (timbra col VS16 i BMP larghi 2, sostituisce
396
+ * i discordi) — applicarla più a valle sposterebbe gli offset degli span appena
397
+ * calcolati. Qui non è una precauzione teorica: un `.md` della doc porta glifi
398
+ * che Ink e il terminale misurano diversamente (`↔` in una testata, `⚡`/`✔️` in
399
+ * una tabella), e senza la sanificazione il wrap conta una colonna in meno di
400
+ * quante Ink ne disegna — la riga esce dal box e ne mangia il bordo.
401
+ */
402
+ export function loadDocText(projectRoot, relPath) {
403
+ try {
404
+ return sanitize(readFileSync(join(projectRoot, relPath), 'utf8'));
405
+ }
406
+ catch {
407
+ return null;
408
+ }
409
+ }
410
+ /**
411
+ * P10 — `⏎` su una CARTELLA apre lo stesso sheet, con l'elenco dei suoi file al
412
+ * posto del testo.
413
+ *
414
+ * Il gesto è identico su file e cartella (`⏎⏎`), quindi anche la schermata deve
415
+ * esserlo: cambia cosa si legge nel corpo, non cosa si preme. Per un file è il
416
+ * suo testo, per una cartella l'elenco che dice PERCHÉ la cartella è flaggata —
417
+ * quanti file, quanto pesano, quali portano un flag proprio. Senza, `⏎` su una
418
+ * riga `REGROUP` aprirebbe un corpo vuoto e la decisione andrebbe presa alla
419
+ * cieca.
420
+ *
421
+ * Markdown perché lo sheet lo rende (`parseMarkdown`): il titolo diventa un
422
+ * heading e i backtick una cella di codice, senza che questa funzione sappia
423
+ * niente della resa.
424
+ */
425
+ export function dirListing(data, dirPath) {
426
+ const dir = data.dirs.find((d) => d.path === dirPath);
427
+ const own = data.files
428
+ .filter((f) => parentOf(f.path) === dirPath)
429
+ .sort((a, b) => b.chars - a.chars);
430
+ const head = [
431
+ `# ${dirPath}`,
432
+ '',
433
+ `${dir?.files ?? own.length} file · ${dir?.chars ?? 0} char${dir && dir.flags.length > 0 ? ` · ${flagNames(dir.flags).join(' · ')}` : ''}`,
434
+ '',
435
+ ];
436
+ if (own.length === 0) {
437
+ return [...head, 'Nessun file direttamente in questa cartella: il peso viene dalle sottocartelle.'].join('\n');
438
+ }
439
+ const rows = own.map((f) => {
440
+ const flags = f.flags.length > 0 ? ` — ${flagNames(f.flags).join(' · ')}` : '';
441
+ return `- \`${lastSegment(f.path)}\` · ${f.chars} char · TLDR ${f.tldr || 'assente'}${flags}`;
442
+ });
443
+ return [...head, ...rows].join('\n');
444
+ }
445
+ /**
446
+ * Invoca la misura e ne parsa l'output.
447
+ *
448
+ * I modi di non avere il dato — plugin non installato su questa macchina, script
449
+ * che esce male, timeout — collassano in un solo `ok: false`, come per lo scan
450
+ * inbox: chi vede l'allerta chiede a Claude di indagare, e distinguerli a
451
+ * schermo non cambierebbe la prima mossa. Nessuno dei due è un throw: un pane
452
+ * informativo non può rompere il deck.
453
+ *
454
+ * `maxBuffer` largo il doppio di quello dello scan inbox: qui l'output è una
455
+ * riga per `.md` del progetto più una per cartella, non una quindicina di righe.
456
+ */
457
+ export async function scanDocTree(projectRoot, docsRoot) {
458
+ const script = pluginScript(DOC_METRICS_SCRIPT);
459
+ if (!script)
460
+ return { files: [], dirs: [], hasDirs: false, ok: false };
461
+ try {
462
+ const { stdout } = await execFileAsync(script, ['--docs-root', docsRoot, '--format', 'tsv'], { cwd: projectRoot, maxBuffer: 8 * 1024 * 1024 });
463
+ return { ...parseDocTsv(stdout), ok: true };
464
+ }
465
+ catch {
466
+ return { files: [], dirs: [], hasDirs: false, ok: false };
467
+ }
468
+ }
@@ -0,0 +1,117 @@
1
+ // T160 — Catalogo delle viste del pane doc.
2
+ //
3
+ // Gemello di `inbox-views.ts` per forma e invarianti, MODULO SEPARATO per il
4
+ // tipo: le righe di questo pane sono `DocRow` e le sue azioni (`⏎` sul detail,
5
+ // spawn di `rebalance-doc` sul bersaglio) sono scritte per un file o una
6
+ // cartella della doc. Infilarle nel catalogo inbox obbligherebbe a un'unione
7
+ // discriminata e a rendere inerte ogni azione sulla riga sbagliata — la stessa
8
+ // ragione per cui T134 aveva tenuto separato il proprio.
9
+ //
10
+ // Modulo PURO: nessun import da ink/react, nessun I/O. La misura la fa
11
+ // `doc-metrics.sh`, la parsa `doc-tree.ts`, e qui c'è solo la mappa di quali
12
+ // sottoinsiemi esistono, come si chiamano e con che numero.
13
+ //
14
+ // Invarianti ereditate dai tre cataloghi già in esercizio:
15
+ // - catalogo FISSO: le voci ci sono tutte anche a contatore 0, o navigando si
16
+ // spostano sotto le dita;
17
+ // - navigazione CICLICA con `tab`;
18
+ // - il numero di una voce coincide col numero di RIGHE che la voce mostra.
19
+ // Su un pane ad albero questo vincola anche la forma delle viste: `Tutti`
20
+ // conta le cartelle insieme ai file, perché le mostra entrambe, e le viste
21
+ // per flag sono PIATTE — elencano i soli elementi flaggati, senza le cartelle
22
+ // antenate, perché ogni riga di struttura in più farebbe divergere il
23
+ // contatore dalla lista.
24
+ import { buildDocTree, countFlagged, flatDocRows, } from './doc-tree.js';
25
+ /**
26
+ * I flag di ogni vista per flag.
27
+ *
28
+ * `tldr` ne somma TRE. Sono tre difetti diversi della stessa riga 3 — troppo
29
+ * lunga, assente, con un'ancora che nel corpo non c'è — ma chiamano tutti e tre
30
+ * lo stesso rimedio (`write-tldr` sul file), quindi separarli darebbe tre voci
31
+ * di catalogo che si guardano insieme e si eseguono insieme. Le short di riga
32
+ * restano distinte (`CAP`, `NOT`, `ORF`): quale dei tre sia lo dice la riga, non
33
+ * la vista.
34
+ */
35
+ const VIEW_FLAGS = {
36
+ split: ['SPLIT'],
37
+ merge: ['MERGE?'],
38
+ tldr: ['TLDR>CAP', 'NOTLDR', 'TLDR-ORFANA'],
39
+ regroup: ['REGROUP'],
40
+ };
41
+ /** Quanto lavoro di topologia e di ancore la misura ha trovato: la somma delle
42
+ * quattro viste per flag. È il numero su cui `Tutti` decide se grigiarsi —
43
+ * l'albero di una doc in equilibrio si guarda lo stesso, ma non chiede niente. */
44
+ export function flaggedTotal(c) {
45
+ return c.split + c.merge + c.tldr + c.regroup;
46
+ }
47
+ /** I contatori dell'header, da una fonte sola: gli stessi numeri che
48
+ * dimensionano le quattro viste per flag, quindi non possono dire cose diverse
49
+ * da quelle che le liste mostrano. */
50
+ export function docCounts(data) {
51
+ return {
52
+ total: buildDocTree(data).length,
53
+ split: countFlagged(data, VIEW_FLAGS.split),
54
+ merge: countFlagged(data, VIEW_FLAGS.merge),
55
+ tldr: countFlagged(data, VIEW_FLAGS.tldr),
56
+ regroup: countFlagged(data, VIEW_FLAGS.regroup),
57
+ };
58
+ }
59
+ const FLAG_VIEWS = [
60
+ {
61
+ id: 'split',
62
+ label: (c) => `SPLIT (${c.split})`,
63
+ count: (c) => c.split,
64
+ dim: (c) => c.split === 0,
65
+ rows: (data) => flatDocRows(data, VIEW_FLAGS.split),
66
+ empty: 'nessun file sopra la soglia di split',
67
+ },
68
+ {
69
+ id: 'merge',
70
+ label: (c) => `MERGE? (${c.merge})`,
71
+ count: (c) => c.merge,
72
+ dim: (c) => c.merge === 0,
73
+ rows: (data) => flatDocRows(data, VIEW_FLAGS.merge),
74
+ empty: 'nessun file sotto il pavimento di merge',
75
+ },
76
+ {
77
+ id: 'tldr',
78
+ label: (c) => `TLDR (${c.tldr})`,
79
+ count: (c) => c.tldr,
80
+ dim: (c) => c.tldr === 0,
81
+ rows: (data) => flatDocRows(data, VIEW_FLAGS.tldr),
82
+ empty: 'nessuna riga 3 fuori posto',
83
+ },
84
+ {
85
+ id: 'regroup',
86
+ label: (c) => `REGROUP (${c.regroup})`,
87
+ count: (c) => c.regroup,
88
+ dim: (c) => c.regroup === 0,
89
+ rows: (data) => flatDocRows(data, VIEW_FLAGS.regroup),
90
+ empty: 'nessuna cartella oltre la soglia di regroup',
91
+ },
92
+ ];
93
+ export const DOC_VIEWS = [
94
+ {
95
+ id: 'all',
96
+ label: (c) => `Tutti (${c.total})`,
97
+ count: (c) => c.total,
98
+ dim: (c) => flaggedTotal(c) === 0,
99
+ rows: (data) => buildDocTree(data),
100
+ empty: 'nessun file sotto la docs-root',
101
+ },
102
+ ...FLAG_VIEWS,
103
+ ];
104
+ export function docView(id) {
105
+ return DOC_VIEWS.find((v) => v.id === id) ?? DOC_VIEWS[0];
106
+ }
107
+ export function selectDocRows(id, data) {
108
+ return docView(id).rows(data);
109
+ }
110
+ /** Scorrimento ciclico, come gli altri tre cataloghi: `→` sull'ultima voce torna
111
+ * alla prima. Id ignoto → prima voce, mai un indice negativo. */
112
+ export function cycleDocView(current, delta) {
113
+ const at = DOC_VIEWS.findIndex((v) => v.id === current);
114
+ if (at < 0)
115
+ return DOC_VIEWS[0].id;
116
+ return DOC_VIEWS[(at + delta + DOC_VIEWS.length) % DOC_VIEWS.length].id;
117
+ }