@lamemind/loom-deck 0.70.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.
- package/dist/actions.js +55 -0
- package/dist/cli.js +39 -13
- package/dist/deck-model.js +113 -31
- package/dist/doc-tree.js +468 -0
- package/dist/doc-views.js +117 -0
- package/dist/frame.js +51 -23
- package/dist/hooks.js +45 -0
- package/dist/inbox.js +10 -1
- package/dist/input-modes.js +38 -1
- package/dist/input.js +90 -21
- package/dist/model.js +11 -0
- package/dist/mouse.js +18 -8
- package/dist/overlays/file-sheet.js +101 -0
- package/dist/pane-header.js +42 -0
- package/dist/sane-note.js +4 -4
- package/dist/spawn-catalog.js +40 -4
- package/dist/ui/doc-pane.js +85 -0
- package/dist/ui/file-sheet-screen.js +58 -0
- package/dist/ui/header-line.js +19 -0
- package/dist/ui/inbox-pane.js +83 -0
- package/dist/ui/panes.js +13 -74
- package/dist/ui/preview.js +25 -1
- package/dist/ui/screens.js +16 -4
- package/dist/viewport.js +41 -18
- package/package.json +1 -1
- package/scripts/deck-run +6 -5
package/dist/doc-tree.js
ADDED
|
@@ -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
|
+
}
|