@lamemind/loom-deck 0.55.0 → 0.56.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 +291 -0
- package/dist/cli.js +130 -1584
- package/dist/deck-model.js +348 -0
- package/dist/frame.js +178 -0
- package/dist/inbox.js +135 -0
- package/dist/input.js +469 -0
- package/dist/overlays/modals.js +231 -0
- package/dist/overlays/purge.js +44 -0
- package/dist/plugin-cache.js +106 -0
- package/dist/task-ops.js +235 -0
- package/dist/ui/screens.js +201 -0
- package/dist/wrap-scan.js +122 -0
- package/package.json +1 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// Le SCHERMATE SOSTITUTIVE del deck e i due frammenti che le accompagnano.
|
|
3
|
+
//
|
|
4
|
+
// Una schermata sostitutiva prende il frame intero invece di stare in un box
|
|
5
|
+
// sopra i due pane: assegnazione, detail della task, project status, ricerca e
|
|
6
|
+
// reader. Il criterio è la taglia del contenuto — una lista di occorrenze o un
|
|
7
|
+
// task file non entrano in quattro righe — e la conseguenza è che il budget
|
|
8
|
+
// d'altezza dei pane non viene nemmeno calcolato, perché il render esce prima.
|
|
9
|
+
//
|
|
10
|
+
// `screenFor` è il ROUTER: sceglie fra le cinque e restituisce `null` quando
|
|
11
|
+
// nessuna è attiva, cioè quando si resta sulla lista. È una funzione e non un
|
|
12
|
+
// componente proprio per questo — il chiamante deve poter distinguere «ecco la
|
|
13
|
+
// schermata» da «non è il tuo turno», e un componente che rende `null` non gli
|
|
14
|
+
// direbbe la differenza in tempo utile per il proprio `return`.
|
|
15
|
+
//
|
|
16
|
+
// Fino a T131 le cinque vivevano in `cli.tsx` come cinque `if` consecutivi, con
|
|
17
|
+
// dentro le derivazioni di finestra di ognuna. Sono uscite insieme perché la
|
|
18
|
+
// scelta fra le viste non è né vista né input: la vista disegna ciò che le
|
|
19
|
+
// viene dato, l'input decide cosa cambiare, e chi sceglie quale albero tornare
|
|
20
|
+
// è un terzo mestiere.
|
|
21
|
+
//
|
|
22
|
+
// Direzione della dipendenza: questo file importa i TIPI di ritorno degli hook
|
|
23
|
+
// di `src/overlays/` (`import type` + `ReturnType`, nessun import a runtime).
|
|
24
|
+
// È lecito nell'asse — la vista sta a valle dell'input, mai a monte — ed è il
|
|
25
|
+
// primo file di `ui/` a farlo, quindi va detto invece che scoperto a grep.
|
|
26
|
+
import { Box, Text } from 'ink';
|
|
27
|
+
import { rowIndexOfKey, selectedRow } from '../search.js';
|
|
28
|
+
import { isCompact, searchPreviewCapacity, windowRange } from '../viewport.js';
|
|
29
|
+
import { conversationLabel } from '../layout.js';
|
|
30
|
+
import { taskColumns } from '../view.js';
|
|
31
|
+
import { AssignScreen } from './assign-screen.js';
|
|
32
|
+
import { DetailScreen } from './detail-screen.js';
|
|
33
|
+
import { StatusScreen } from './status-screen.js';
|
|
34
|
+
import { ReaderScreen, SearchScreen } from './search-screen.js';
|
|
35
|
+
/**
|
|
36
|
+
* Il ripiego per un terminale troppo basso: una riga sola al posto della
|
|
37
|
+
* cornice. Perdere il layout è meglio che sfondare `rows` — oltre quella
|
|
38
|
+
* soglia Ink smette di aggiornare per differenza e pulisce lo schermo a ogni
|
|
39
|
+
* redraw, versando un frame intero nello scrollback a ogni tick del poll.
|
|
40
|
+
*
|
|
41
|
+
* Sei call site lo usavano in copia (le cinque schermate più la lista), identici
|
|
42
|
+
* tranne il soggetto e il verbo di `esc`. `dot` esiste perché la sesta copia —
|
|
43
|
+
* quella della lista — attacca al nome del programma la propria VERSIONE, e un
|
|
44
|
+
* punto in mezzo la staccherebbe da ciò di cui è la versione.
|
|
45
|
+
*/
|
|
46
|
+
export function CompactNotice({ what, esc, rows, columns, dot = true, }) {
|
|
47
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [dot ? ' · ' : ' ', what, " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga", esc ? ` · esc ${esc}` : ''] })] }));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* La riga sotto la testata: in `normal` la legenda dei tasti, in un modale in
|
|
51
|
+
* flusso le sue istruzioni. I modali sostitutivi non arrivano qui — hanno già
|
|
52
|
+
* preso il frame.
|
|
53
|
+
*
|
|
54
|
+
* La legenda di `normal` la calcola il chiamante (`deckLegend` in `frame.ts`),
|
|
55
|
+
* perché dipende da cosa è selezionato; le sette righe dei modali sono testo
|
|
56
|
+
* fisso e vivono qui, accanto alla forma che le rende.
|
|
57
|
+
*/
|
|
58
|
+
export function HintBar({ mode, purge, keyLegend, }) {
|
|
59
|
+
if (mode === 'create') {
|
|
60
|
+
return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }));
|
|
61
|
+
}
|
|
62
|
+
if (mode === 'sort') {
|
|
63
|
+
return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }));
|
|
64
|
+
}
|
|
65
|
+
if (mode === 'filter') {
|
|
66
|
+
return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }));
|
|
67
|
+
}
|
|
68
|
+
if (mode === 'note') {
|
|
69
|
+
return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["titolo conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }));
|
|
70
|
+
}
|
|
71
|
+
if (mode === 'purge') {
|
|
72
|
+
return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["elimina task \u00B7", ' ', purge?.ignored ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " keep/purge dei file non tracciati \u00B7", ' '] })) : null, _jsx(Text, { color: "yellow", children: "\u23CE" }), " conferma \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }));
|
|
73
|
+
}
|
|
74
|
+
if (mode === 'edit') {
|
|
75
|
+
return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }));
|
|
76
|
+
}
|
|
77
|
+
return (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend }));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Sceglie la schermata sostitutiva attiva, o `null` per restare sulla lista.
|
|
81
|
+
*
|
|
82
|
+
* L'ordine dei rami è quello che avevano in `cli.tsx` e non è indifferente: i
|
|
83
|
+
* modi si escludono a vicenda, ma `detail` e `status` chiedono anche che il
|
|
84
|
+
* proprio contenuto esista (`sheet.sheet`, `status.view`) — un modo dichiarato
|
|
85
|
+
* senza contenuto deve cadere alla lista, non a una schermata vuota.
|
|
86
|
+
*/
|
|
87
|
+
export function screenFor(input) {
|
|
88
|
+
const { mode, rows, columns, note, overlays } = input;
|
|
89
|
+
const { assign, sheet, search, status } = overlays;
|
|
90
|
+
// ── T57 · schermata di assegnazione ─────────────────────────────────────
|
|
91
|
+
// Sostitutiva come ricerca e reader (D3): la lista task non entra in un box
|
|
92
|
+
// sopra i due pane, e prendendo l'intero frame non costa nulla al loro budget.
|
|
93
|
+
// Conseguenza obbligata della scelta: la sessione in assegnazione non è più
|
|
94
|
+
// visibile, quindi va RIPETUTA nel titolo — senza, non si sa più su cosa si
|
|
95
|
+
// sta agendo.
|
|
96
|
+
if (mode === 'assign') {
|
|
97
|
+
if (isCompact(assign.capacity)) {
|
|
98
|
+
return _jsx(CompactNotice, { what: "assegna", esc: "annulla", rows: rows, columns: columns });
|
|
99
|
+
}
|
|
100
|
+
const at = assign.list.findIndex((t) => (t?.id ?? null) === assign.sel);
|
|
101
|
+
const win = windowRange(assign.list.length, at, assign.capacity);
|
|
102
|
+
const s = assign.sid
|
|
103
|
+
? input.sessions.find((x) => x.sessionId === assign.sid) ?? null
|
|
104
|
+
: null;
|
|
105
|
+
// Etichetta della conversazione: la nota umana se c'è (è il nome con cui la
|
|
106
|
+
// riconosci), altrimenti la stessa derivazione della ricerca. Su una pinnata
|
|
107
|
+
// stale non resta nulla: il titolo si accontenta dell'hash.
|
|
108
|
+
const label = (assign.sid ? input.sessionNotes.get(assign.sid) : '') ||
|
|
109
|
+
(s
|
|
110
|
+
? conversationLabel(s, input.projectCore, assign.sid ? input.bindings.get(assign.sid) : undefined)
|
|
111
|
+
: '');
|
|
112
|
+
// T124 — le colonne si misurano sulla lista FILTRATA di questa schermata,
|
|
113
|
+
// non su `paneTasks`: la popolazione è un'altra, e riusare le larghezze del
|
|
114
|
+
// pane darebbe una colonna dimensionata su righe che qui non ci sono.
|
|
115
|
+
// `assign.list` porta in testa la riga `detach` (`null`), che non è una task.
|
|
116
|
+
const assignCols = taskColumns(assign.list.filter((t) => t !== null), input.taskRowData);
|
|
117
|
+
return (_jsx(AssignScreen, { sessionId: assign.sid ?? '', label: label, current: assign.sid ? input.bindings.get(assign.sid) ?? null : null, filter: assign.filter, rows: assign.list.slice(win.start, win.end), selected: assign.sel, matched: assign.list.length - 1, hidden: input.hiddenTasks, above: win.start, below: assign.list.length - win.end, idW: assignCols.id, tailW: assignCols.tail, data: input.taskRowData, columns: columns, note: note }));
|
|
118
|
+
}
|
|
119
|
+
// ── T66 · detail della task ─────────────────────────────────────────────
|
|
120
|
+
// Quarta schermata sostitutiva, stessa ragione delle altre tre: un task file
|
|
121
|
+
// non entra in un box sopra i due pane. Il budget dei pane non viene nemmeno
|
|
122
|
+
// calcolato — il render esce di qui prima.
|
|
123
|
+
if (mode === 'detail' && sheet.sheet) {
|
|
124
|
+
if (isCompact(sheet.capacity)) {
|
|
125
|
+
return (_jsx(CompactNotice, { what: sheet.sheet.id, esc: "chiude", rows: rows, columns: columns }));
|
|
126
|
+
}
|
|
127
|
+
// Niente `windowRange`: quella centra la finestra su una selezione, qui la
|
|
128
|
+
// posizione è lo scroll mosso a mano. Il clamp serve comunque — un resize
|
|
129
|
+
// può accorciare il testo sotto uno scroll già dato.
|
|
130
|
+
const start = Math.min(sheet.top, sheet.maxTop);
|
|
131
|
+
return (_jsx(DetailScreen, { id: sheet.sheet.id, title: sheet.sheet.title, missing: sheet.sheet.text === null, lines: sheet.lines.slice(start, start + sheet.capacity), spans: sheet.doc?.spans ?? [], top: start, total: sheet.lines.length, capacity: sheet.capacity, action: sheet.action, model: sheet.model, spawnNote: sheet.spawnNote, prompt: sheet.prompt, cursor: sheet.cursor, columns: columns, find: sheet.find, occ: sheet.findRes.occ, occCur: sheet.occCur }));
|
|
132
|
+
}
|
|
133
|
+
// ── T121 · viewer del project status ────────────────────────────────────
|
|
134
|
+
// Quinta schermata sostitutiva, stessa ragione delle altre quattro: un recap
|
|
135
|
+
// di progetto è lungo quanto un task file e non entra in un box sopra i pane.
|
|
136
|
+
if (mode === 'status' && status.view) {
|
|
137
|
+
if (isCompact(status.capacity)) {
|
|
138
|
+
return (_jsx(CompactNotice, { what: "project status", esc: "chiude", rows: rows, columns: columns }));
|
|
139
|
+
}
|
|
140
|
+
// Il clamp serve anche qui: un resize può accorciare il testo sotto uno
|
|
141
|
+
// scroll già dato.
|
|
142
|
+
const start = Math.min(status.top, status.maxTop);
|
|
143
|
+
return (_jsx(StatusScreen, { name: input.projectCore ?? input.projectName, label: status.label, building: status.building, failed: status.failed, view: status.view, lines: status.lines.slice(start, start + status.capacity), spans: status.doc?.spans ?? [], top: start, total: status.lines.length, capacity: status.capacity, columns: columns }));
|
|
144
|
+
}
|
|
145
|
+
// ── T52 · ricerca e reader ──────────────────────────────────────────────
|
|
146
|
+
// Gli unici modali che NON stanno in flusso sopra i pane: una lista di
|
|
147
|
+
// occorrenze non entra in un box da 4 righe. Prendono l'intero frame, quindi
|
|
148
|
+
// escono di qui — il budget dei due pane sotto non serve nemmeno calcolarlo,
|
|
149
|
+
// e la loro altezza la distribuiscono `searchListCapacity` / `readerCapacity`.
|
|
150
|
+
if (mode === 'search' || mode === 'reader') {
|
|
151
|
+
const hit = mode === 'reader' && search.readerRow?.kind === 'hit' ? search.readerRow.hit : null;
|
|
152
|
+
// Terminale sotto la cornice: riga singola invece del box, per lo stesso
|
|
153
|
+
// motivo del `budget.compact` del deck — un frame più alto di `rows` fa
|
|
154
|
+
// pulire lo schermo a Ink a ogni redraw, e il poll lo versa nello scrollback.
|
|
155
|
+
if (isCompact(hit ? search.readerCap : search.listCap)) {
|
|
156
|
+
return (_jsx(CompactNotice, { what: hit ? 'reader' : 'ricerca', esc: hit ? 'torna' : 'chiude', rows: rows, columns: columns }));
|
|
157
|
+
}
|
|
158
|
+
if (hit) {
|
|
159
|
+
// Niente `windowRange`: quella centra la finestra su una SELEZIONE, qui
|
|
160
|
+
// la posizione è lo scroll che l'utente muove a mano. Il clamp serve
|
|
161
|
+
// comunque — un resize può accorciare il testo sotto uno scroll già dato.
|
|
162
|
+
const start = Math.min(search.readerTop, search.readerMaxTop);
|
|
163
|
+
return (_jsx(ReaderScreen, { hit: hit, lines: search.readerLines.slice(start, start + search.readerCap), top: start, total: search.readerLines.length, capacity: search.readerCap, bound: input.bindings.get(hit.sessionId) ?? null }));
|
|
164
|
+
}
|
|
165
|
+
const selIdx = rowIndexOfKey(search.rows, search.selKey);
|
|
166
|
+
const win = windowRange(search.rows.length, selIdx, search.listCap);
|
|
167
|
+
// Anteprima dell'occorrenza selezionata: prende le righe che la lista non
|
|
168
|
+
// usa. Con molti risultati `spare` è 0 e il pannello non esiste — la lista
|
|
169
|
+
// se le riprende tutte, che è la priorità giusta quando c'è molto da
|
|
170
|
+
// scorrere. La finestra si CENTRA sul match (`windowRange`), così il
|
|
171
|
+
// contesto arriva da entrambi i lati.
|
|
172
|
+
const spare = searchPreviewCapacity(search.listCap, win.end - win.start);
|
|
173
|
+
let preview = null;
|
|
174
|
+
if (spare >= 1 && search.selRow?.kind === 'hit') {
|
|
175
|
+
const h = search.selRow.hit;
|
|
176
|
+
const mline = Math.max(0, search.previewBody.findIndex((l) => l.end > h.matchStart));
|
|
177
|
+
const pw = windowRange(search.previewBody.length, mline, spare);
|
|
178
|
+
preview = {
|
|
179
|
+
hit: h,
|
|
180
|
+
lines: search.previewBody.slice(pw.start, pw.end),
|
|
181
|
+
from: pw.start,
|
|
182
|
+
total: search.previewBody.length,
|
|
183
|
+
ts: input.sessions.find((s) => s.sessionId === h.sessionId)?.ts ?? 0,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return (_jsx(SearchScreen, { preview: preview, hash: search.hash, query: search.query, field: search.field, opts: search.opts, result: search.result, rows: search.rows.slice(win.start, win.end), selectedKey: search.selKey, selectedKind: selectedRow(search.rows, search.selKey)?.kind ?? null, above: win.start, below: search.rows.length - win.end, capacity: search.listCap, bindings: input.bindings, pinned: input.pinned, sessionNotes: input.sessionNotes, projectCore: input.projectCore, columns: columns, note: note }));
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* I due box di testo in flusso — create e nota — e i tre modali che li
|
|
192
|
+
* accompagnano stanno in `ui/modals.tsx`; qui resta il solo `CreateBox`, che
|
|
193
|
+
* non aveva una casa perché nato come JSX inline nel corpo del deck.
|
|
194
|
+
*
|
|
195
|
+
* Il cursore sta in coda al testo (append only): un cursore mobile vorrebbe
|
|
196
|
+
* gestire frecce e Home/End, e `Home`/`End` non sono nemmeno esposte da
|
|
197
|
+
* `useInput`.
|
|
198
|
+
*/
|
|
199
|
+
export function TextBox({ glyph, value }) {
|
|
200
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: "yellow", children: [glyph, " \u203A "] }), _jsx(Text, { children: value }), _jsx(Text, { inverse: true, children: " " })] }));
|
|
201
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// T134 — l'hard-wrap dei `.md` come dato del deck.
|
|
2
|
+
//
|
|
3
|
+
// Lo scanner è `scripts/docs/md-wrap.py` del plugin, e resta lì per intero: il
|
|
4
|
+
// riconoscimento del wrap sta in tre euristiche tarate su un collaudo (la
|
|
5
|
+
// colonna è una banda con tolleranza, si stima sul 90° percentile, lo
|
|
6
|
+
// srotolamento itera a colonna ferma) e replicarle qui darebbe due misure
|
|
7
|
+
// destinate a divergere in silenzio.
|
|
8
|
+
//
|
|
9
|
+
// A differenza della coda inbox, questa misura è CARA: cammina l'albero del
|
|
10
|
+
// progetto intero, submodule compresi. Da qui la forma già in casa per il
|
|
11
|
+
// project status — generare e aprire su due tasti distinti, con una cache su
|
|
12
|
+
// disco in mezzo — invece di uno scan all'avvio che rallenterebbe proprio il
|
|
13
|
+
// momento in cui si vuole vedere qualcosa subito.
|
|
14
|
+
import { execFile } from 'node:child_process';
|
|
15
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { promisify } from 'node:util';
|
|
19
|
+
import { pluginScript } from './plugin-cache.js';
|
|
20
|
+
const execFileAsync = promisify(execFile);
|
|
21
|
+
export const WRAP_SCRIPT = 'scripts/docs/md-wrap.py';
|
|
22
|
+
const FIELD_RE = /^(col|ratio|breaks|prose)=(.*)$/;
|
|
23
|
+
/**
|
|
24
|
+
* Il TSV di `md-wrap.py --scan`: una riga per file NON `libero`, nella forma
|
|
25
|
+
* `verdetto ⇥ path ⇥ col=N ⇥ ratio=N ⇥ breaks=N ⇥ prose=N`.
|
|
26
|
+
*
|
|
27
|
+
* I campi si leggono per nome e non per posizione: sono già etichettati
|
|
28
|
+
* nell'output, e leggerli per indice trasformerebbe una colonna aggiunta in
|
|
29
|
+
* fondo in numeri sbagliati invece che in un campo ignorato.
|
|
30
|
+
*/
|
|
31
|
+
export function parseWrapTsv(stdout) {
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const line of stdout.split('\n')) {
|
|
34
|
+
if (!line.trim())
|
|
35
|
+
continue;
|
|
36
|
+
const cells = line.split('\t');
|
|
37
|
+
const verdict = cells[0];
|
|
38
|
+
if (verdict !== 'WRAP' && verdict !== 'misto')
|
|
39
|
+
continue;
|
|
40
|
+
if (!cells[1])
|
|
41
|
+
continue;
|
|
42
|
+
const fields = {};
|
|
43
|
+
for (const cell of cells.slice(2)) {
|
|
44
|
+
const m = FIELD_RE.exec(cell.trim());
|
|
45
|
+
if (m)
|
|
46
|
+
fields[m[1]] = m[2];
|
|
47
|
+
}
|
|
48
|
+
const column = Number(fields.col);
|
|
49
|
+
out.push({
|
|
50
|
+
verdict,
|
|
51
|
+
path: cells[1],
|
|
52
|
+
column: Number.isFinite(column) ? column : null,
|
|
53
|
+
ratio: Number(fields.ratio) || 0,
|
|
54
|
+
breaks: Number(fields.breaks) || 0,
|
|
55
|
+
prose: Number(fields.prose) || 0,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
// I peggiori in cima: chi apre la lista srotola da lì.
|
|
59
|
+
return out.sort((a, b) => {
|
|
60
|
+
if (a.verdict !== b.verdict)
|
|
61
|
+
return a.verdict === 'WRAP' ? -1 : 1;
|
|
62
|
+
return b.breaks - a.breaks;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* D4 — il contatore conta i soli `WRAP`.
|
|
67
|
+
*
|
|
68
|
+
* `misto` ospita il falso allarme strutturale: file di note scritte una riga per
|
|
69
|
+
* pensiero, che nessuno vuole srotolare. Sommarlo darebbe un numero che non si
|
|
70
|
+
* può portare a zero, e un contatore che non arriva mai a zero smette di essere
|
|
71
|
+
* letto.
|
|
72
|
+
*/
|
|
73
|
+
export function wrapCount(files) {
|
|
74
|
+
return files.filter((f) => f.verdict === 'WRAP').length;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Cartella della cache, una per utente e con `mode 0700` — stessa ragione del
|
|
78
|
+
* project status: `/tmp` è condivisa, e un file a nome prevedibile può essere
|
|
79
|
+
* preceduto dal symlink di un altro utente.
|
|
80
|
+
*/
|
|
81
|
+
export function wrapCacheDir() {
|
|
82
|
+
return join(tmpdir(), `loom-deck-wrap-${process.getuid?.() ?? 0}`);
|
|
83
|
+
}
|
|
84
|
+
export function wrapCacheFile(projectRoot) {
|
|
85
|
+
const env = process.env.LOOM_DECK_WRAP_FILE;
|
|
86
|
+
if (env)
|
|
87
|
+
return env;
|
|
88
|
+
return join(wrapCacheDir(), `${projectRoot.replace(/[^a-zA-Z0-9]/g, '-')}.tsv`);
|
|
89
|
+
}
|
|
90
|
+
/** File assente o illeggibile → nessuna cache: `missing` è lo stato di partenza. */
|
|
91
|
+
export function readWrapCache(path) {
|
|
92
|
+
try {
|
|
93
|
+
const raw = readFileSync(path, 'utf8');
|
|
94
|
+
return { files: parseWrapTsv(raw), mtime: statSync(path).mtimeMs };
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Lo scan, in headless. `false` = guasto — plugin assente, `python3` assente,
|
|
102
|
+
* script in errore: i tre casi collassano, perché a schermo non cambiano la
|
|
103
|
+
* prima mossa di chi li vede.
|
|
104
|
+
*
|
|
105
|
+
* La cache si riscrive SOLO su successo: un tentativo fallito lascia in piedi
|
|
106
|
+
* l'esito dell'ultimo scan riuscito, che resta vero e ancora apribile.
|
|
107
|
+
*/
|
|
108
|
+
export async function runWrapScan(projectRoot) {
|
|
109
|
+
const script = pluginScript(WRAP_SCRIPT);
|
|
110
|
+
if (!script)
|
|
111
|
+
return false;
|
|
112
|
+
try {
|
|
113
|
+
const { stdout } = await execFileAsync('python3', [script, '--root', projectRoot, '--scan'], { cwd: projectRoot, maxBuffer: 16 * 1024 * 1024 });
|
|
114
|
+
const path = wrapCacheFile(projectRoot);
|
|
115
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
116
|
+
writeFileSync(path, stdout, { mode: 0o600 });
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
package/package.json
CHANGED