@lamemind/loom-deck 0.54.1 → 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 -1589
- package/dist/deck-model.js +348 -0
- package/dist/frame.js +178 -0
- package/dist/glyphs.js +0 -4
- 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/pane-views.js +9 -14
- package/dist/plugin-cache.js +106 -0
- package/dist/session-list.js +58 -92
- package/dist/task-ops.js +235 -0
- package/dist/ui/panes.js +17 -20
- package/dist/ui/screens.js +201 -0
- package/dist/wrap-scan.js +122 -0
- package/package.json +1 -1
package/dist/task-ops.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// Le operazioni su una TASK: crearla, editarla, potarla.
|
|
2
|
+
//
|
|
3
|
+
// T131 — nate insieme agli spawn dentro `cli.tsx` e separate da quelli qui,
|
|
4
|
+
// sulla linea di frattura che l'analisi dello split aveva indicato: ciò che
|
|
5
|
+
// apre una conversazione da una parte, ciò che ordina un lavoro sulla task list
|
|
6
|
+
// dall'altra. Il discriminante operativo è l'OGGETTO — una sessione contro una
|
|
7
|
+
// riga di `tasks.md` — e si vede dagli effetti: qui si scrive il task file, si
|
|
8
|
+
// committa, si delega a una skill headless.
|
|
9
|
+
//
|
|
10
|
+
// Il deck non rimuove e non riscrive mai la task list da sé: `createTask` e
|
|
11
|
+
// `purgeTasks` ordinano a `loom-works:create-task` e `loom-works:clean-tasks`,
|
|
12
|
+
// che quelle sequenze le implementano già una volta. Averne due significherebbe
|
|
13
|
+
// due creazioni e due rimozioni capaci di divergere.
|
|
14
|
+
//
|
|
15
|
+
// Le stringhe di `setNote` di questo file sono ASSERITE dal gate
|
|
16
|
+
// `test/modes-smoke.test.ts` (`eliminare N task?`, `nessun push`, `scartate`,
|
|
17
|
+
// `tasks.md`): si copiano verbatim, non si migliorano di passaggio.
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import { loadTasks } from './tasks.js';
|
|
20
|
+
import { appendTaskBinding } from './task-index.js';
|
|
21
|
+
import { cut, sanitize } from './width.js';
|
|
22
|
+
import { idList } from './ui/modals.js';
|
|
23
|
+
import { purgeTargets, splitTargets } from './purge.js';
|
|
24
|
+
import { initialDetail, writeTaskEdit, PRI_GLYPH, PRI_LABEL } from './task-edit.js';
|
|
25
|
+
import { priName, progName } from './view.js';
|
|
26
|
+
import { commitTaskEdit, spawnCleanTasks, spawnCreateTask, CLAUDE_CMD, } from './spawn.js';
|
|
27
|
+
export function useTaskOps({ cwd, tasksPath, tasksDir, model, setNote, selectedTaskOr, }) {
|
|
28
|
+
// T30 — il taskId nasce DOPO create-task (lo assegna la skill scrivendo
|
|
29
|
+
// tasks.md) → non è noto allo spawn. Il sessionId invece è pinnato qui:
|
|
30
|
+
// snapshot degli id PRIMA, poi al completamento re-leggo tasks.md e il diff dà
|
|
31
|
+
// il nuovo id → appendTaskBinding lega la sessione (scoped).
|
|
32
|
+
function createTask(text) {
|
|
33
|
+
if (!text) {
|
|
34
|
+
setNote('C → create annullato (vuoto)');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const sid = randomUUID();
|
|
38
|
+
const beforeIds = new Set(model.tasks.map((t) => t.id));
|
|
39
|
+
setNote(`⏳ creando task… "${cut(text, 40)}" (sid ${sid.slice(0, 8)})`);
|
|
40
|
+
const child = spawnCreateTask(text, cwd, sid, (ok) => {
|
|
41
|
+
if (!ok) {
|
|
42
|
+
setNote(`⚠ create-task fallito (${CLAUDE_CMD} -p)`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
let newId;
|
|
46
|
+
try {
|
|
47
|
+
newId = loadTasks(tasksPath).find((t) => !beforeIds.has(t.id))?.id;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// tasks.md illeggibile → id non rilevato, sotto
|
|
51
|
+
}
|
|
52
|
+
if (newId) {
|
|
53
|
+
appendTaskBinding(cwd, sid, newId);
|
|
54
|
+
setNote(`✔ ${newId} creata · sessione scoped (sid ${sid.slice(0, 8)})`);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
setNote(`✔ task creata (id non rilevato) · sid ${sid.slice(0, 8)}`);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
child.on('error', () => setNote(`⚠ create-task: '${CLAUDE_CMD}' non lanciabile`));
|
|
61
|
+
}
|
|
62
|
+
// T41 — scrive tasks.md + task file, poi committa. Il commit è immediato e non
|
|
63
|
+
// confermato (scelta esplicita: l'edit è una micro-modifica, la storia
|
|
64
|
+
// granulare vale più di un batch). Se nessuno dei due lati è stato scritto non
|
|
65
|
+
// si committa nulla — `paths` vuoto renderebbe `git commit --` un commit di
|
|
66
|
+
// TUTTO il working tree, che è l'opposto di ciò che vogliamo.
|
|
67
|
+
function writeEdit(task, draft) {
|
|
68
|
+
// Il titolo si scrive solo se è CAMBIATO davvero: rimandarlo identico
|
|
69
|
+
// riscriverebbe comunque la cella (collassando spazi ed escape) e sporcherebbe
|
|
70
|
+
// il diff di una riga per un edit di sola priorità. Vuoto → scartato: una
|
|
71
|
+
// task senza descrizione in overview non è più riconoscibile.
|
|
72
|
+
const title = draft.title.trim();
|
|
73
|
+
const titleChanged = title.length > 0 && title !== task.rawDesc.trim();
|
|
74
|
+
let res;
|
|
75
|
+
try {
|
|
76
|
+
res = writeTaskEdit({
|
|
77
|
+
tasksPath,
|
|
78
|
+
tasksDir,
|
|
79
|
+
id: task.id,
|
|
80
|
+
pri: draft.pri,
|
|
81
|
+
prog: draft.prog,
|
|
82
|
+
detail: draft.detail,
|
|
83
|
+
title: titleChanged ? title : undefined,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
setNote(`⚠ ${task.id}: scrittura fallita (${e.message})`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (res.paths.length === 0) {
|
|
91
|
+
setNote(`⚠ ${task.id}: nessun campo aggiornabile (riga o task file assenti)`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const summary = `${PRI_GLYPH[draft.pri]} ${PRI_LABEL[draft.pri]} · ${res.progress}${titleChanged ? ` · "${cut(sanitize(title), 32)}"` : ''}`;
|
|
95
|
+
setNote(`⏳ ${task.id} → ${summary} · commit…`);
|
|
96
|
+
commitTaskEdit(cwd, res.paths, `chore(${task.id}): pri ${PRI_LABEL[draft.pri]} · stato ${res.progress}${titleChanged ? ' · titolo' : ''}`, (ok, err) => {
|
|
97
|
+
setNote(ok
|
|
98
|
+
? `✔ ${task.id} → ${summary} · committato`
|
|
99
|
+
: `⚠ ${task.id} salvato, commit fallito: ${err}`);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* La bozza di conferma per `CANC`, o `null` se non c'è niente da confermare
|
|
104
|
+
* (in tal caso la nota dice già perché).
|
|
105
|
+
*
|
|
106
|
+
* Il tasto è uno e il bersaglio ha due taglie: la task selezionata, o
|
|
107
|
+
* l'insieme intero della vista `archiviabili`. A discriminare è `purgeBulk` —
|
|
108
|
+
* la selezione prima della vista.
|
|
109
|
+
*
|
|
110
|
+
* Il bersaglio del bulk si legge da `paneTasks`, cioè dalla stessa fonte che
|
|
111
|
+
* disegna le righe e alimenta il contatore in header (D6): mai il `Set` grezzo
|
|
112
|
+
* di `archivable.ts`, mai un secondo filtro sullo stato. T100 ha fissato che
|
|
113
|
+
* ciò che si conta e ciò che si mostra siano lo stesso insieme per
|
|
114
|
+
* costruzione; qui l'invariante si estende a ciò che si pota.
|
|
115
|
+
*/
|
|
116
|
+
function purgeDraftFor() {
|
|
117
|
+
// La guardia di focus sta QUI e non solo dentro `selectedTaskOr`: il ramo
|
|
118
|
+
// bulk non passa da quella, perché il suo oggetto è la vista e non la
|
|
119
|
+
// selezione. Senza, `CANC` col focus sulle sessioni potrebbe potare in
|
|
120
|
+
// blocco senza che nessuna task fosse selezionata.
|
|
121
|
+
if (model.focus !== 'tasks') {
|
|
122
|
+
setNote('CANC → eliminare: seleziona una task (← per il pane)');
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const bulk = model.purgeBulk;
|
|
126
|
+
const ids = bulk ? model.paneTasks.map((t) => t.id) : [];
|
|
127
|
+
if (!bulk) {
|
|
128
|
+
const task = selectedTaskOr('CANC', 'eliminare');
|
|
129
|
+
if (!task)
|
|
130
|
+
return null;
|
|
131
|
+
ids.push(task.id);
|
|
132
|
+
}
|
|
133
|
+
if (ids.length === 0) {
|
|
134
|
+
setNote('CANC → nessuna task in vista da eliminare');
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
// Ricalcolo al momento dell'AZIONE, non lettura del campionamento in lista:
|
|
138
|
+
// una folder sporcata dopo l'ultimo scan si presenterebbe come eliminabile.
|
|
139
|
+
const { clean, dirty } = splitTargets(purgeTargets(ids, tasksDir, cwd));
|
|
140
|
+
if (bulk) {
|
|
141
|
+
// Il gate del plugin esce 2 PRIMA di toccare qualsiasi cosa, quindi una
|
|
142
|
+
// sola folder sporca annullerebbe il purge di tutte le altre: si scartano
|
|
143
|
+
// a monte e il modale nomina sia le potate sia le scartate.
|
|
144
|
+
if (clean.length === 0) {
|
|
145
|
+
setNote(`CANC → ${dirty.length} task con file non tracciati in folder: eliminale una per una`);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
ids: clean.map((t) => t.id),
|
|
150
|
+
skipped: dirty.map((t) => t.id),
|
|
151
|
+
bulk: true,
|
|
152
|
+
ignored: null,
|
|
153
|
+
survivors: 0,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
// Singola con superstiti → conferma a TRE uscite (D3): la scelta keep/purge
|
|
157
|
+
// è rara e distruttiva in modo diverso dal purge normale, e qui è visibile
|
|
158
|
+
// invece di stare davanti a ogni potatura.
|
|
159
|
+
const one = clean[0] ?? dirty[0];
|
|
160
|
+
return {
|
|
161
|
+
ids: [one.id],
|
|
162
|
+
skipped: [],
|
|
163
|
+
bulk: false,
|
|
164
|
+
ignored: one.survivors > 0 ? 'keep' : null,
|
|
165
|
+
survivors: one.survivors,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
// ⏎ nella conferma: ordina la potatura a `loom-works:clean-tasks` e la
|
|
169
|
+
// osserva. Il deck non rimuove niente da sé — nessun `git rm`, nessuna
|
|
170
|
+
// riscrittura di tasks.md, nessuna `unlink`: quella sequenza è già
|
|
171
|
+
// implementata una volta, e averne due significherebbe due rimozioni capaci
|
|
172
|
+
// di divergere.
|
|
173
|
+
//
|
|
174
|
+
// La lista si riallinea al primo tick del poll e il set delle archiviabili
|
|
175
|
+
// allo scan che `doneSig` fa scattare quando la popolazione Done cambia; la
|
|
176
|
+
// selezione, keyed su id, cade sulla prima riga della vista (effect di
|
|
177
|
+
// validità) invece che su una posizione residua.
|
|
178
|
+
function purgeTasks(draft) {
|
|
179
|
+
const sid = randomUUID();
|
|
180
|
+
setNote(`⏳ eliminando ${draft.ids.length} task… ${idList(draft.ids, 6)}`);
|
|
181
|
+
const child = spawnCleanTasks(draft.ids, cwd, sid, draft.ignored, (ok, detail) => {
|
|
182
|
+
// L'esito si misura su `tasks.md`, NON sul solo `is_error`. Misurato: col
|
|
183
|
+
// gate `--ignored-files` che blocca, la skill spiega il blocco e chiude
|
|
184
|
+
// comunque `is_error: false` — un successo dichiarato su zero rimozioni.
|
|
185
|
+
// Il segnale robusto è quali degli ID bersaglio non hanno più una riga:
|
|
186
|
+
// deterministico, e indipendente da come la skill racconta sé stessa.
|
|
187
|
+
let survived = draft.ids;
|
|
188
|
+
try {
|
|
189
|
+
const now = new Set(loadTasks(tasksPath).map((t) => t.id));
|
|
190
|
+
survived = draft.ids.filter((id) => now.has(id));
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// tasks.md illeggibile → nessuna verifica possibile, e allora si dice
|
|
194
|
+
// che non è stata fatta invece di dedurre un esito.
|
|
195
|
+
setNote(`⚠ ${draft.ids.length} task: esito non verificabile (tasks.md illeggibile)`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const removed = draft.ids.length - survived.length;
|
|
199
|
+
if (removed === draft.ids.length) {
|
|
200
|
+
// Il push non c'è, per scelta della skill: finché nessuno pusha, un
|
|
201
|
+
// altro worktree continua a vedere le task potate in tasks.md. Non
|
|
202
|
+
// dirlo lascerebbe leggere l'assenza di push come «fatto».
|
|
203
|
+
setNote(`✔ ${removed} task eliminate · commit locali, nessun push`);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
// Il gate `--ignored-files` (exit 2) può scattare lo stesso: la
|
|
207
|
+
// dirtiness è un dato campionato e una folder può sporcarsi fra il
|
|
208
|
+
// ricalcolo e l'apply. Il testo del result event è l'unica cosa che dice
|
|
209
|
+
// PERCHÉ, quindi entra nella riga invece di un ⚠ muto.
|
|
210
|
+
setNote(`⚠ ${removed}/${draft.ids.length} eliminate · restano ${idList(survived, 4)} · ${cut(detail || (ok ? '' : `${CLAUDE_CMD} -p`), 56)}`);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
child?.on('error', () => setNote(`⚠ clean-tasks: '${CLAUDE_CMD}' non lanciabile`));
|
|
214
|
+
}
|
|
215
|
+
/** T41 — la bozza dell'edit seminata dai valori ATTUALI della task. */
|
|
216
|
+
function editDraftFor(task) {
|
|
217
|
+
// La priorità arriva dal glifo di tasks.md (già in `selTask`), lo stato dal
|
|
218
|
+
// suo glifo Prog; il progresso arbitrario dal campo `Progress` del task file
|
|
219
|
+
// — ma solo se è davvero custom (vedi `initialDetail`).
|
|
220
|
+
//
|
|
221
|
+
// Il titolo si semina dalla riga di tasks.md e non dall'H1 del task file per
|
|
222
|
+
// due ragioni: è la fonte che esiste SEMPRE (un task file può mancare), ed è
|
|
223
|
+
// il testo che l'utente sta guardando in lista quando preme `E`. Grezzo
|
|
224
|
+
// (`rawDesc`), non sanificato: rimandare a disco la forma sanificata
|
|
225
|
+
// riscriverebbe i glifi anche senza toccare il campo.
|
|
226
|
+
const prog = progName(task.prog) ?? 'todo';
|
|
227
|
+
return {
|
|
228
|
+
pri: priName(task.pri) ?? 'med',
|
|
229
|
+
prog,
|
|
230
|
+
detail: initialDetail(model.detail?.fields['Progress'] ?? '', prog),
|
|
231
|
+
title: task.rawDesc,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return { createTask, editDraftFor, writeEdit, purgeDraftFor, purgeTasks };
|
|
235
|
+
}
|
package/dist/ui/panes.js
CHANGED
|
@@ -5,7 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
5
5
|
import { Box, Text } from 'ink';
|
|
6
6
|
import { cut, pad, sanitize, termWidth } from '../width.js';
|
|
7
7
|
import { paneTextWidth } from '../layout.js';
|
|
8
|
-
import { CARET, CARET_OFF, LIVE_BUSY, LIVE_IDLE, LIVE_NONE, MODEL_W,
|
|
8
|
+
import { CARET, CARET_OFF, LIVE_BUSY, LIVE_IDLE, LIVE_NONE, MODEL_W, SID_CHARS, TASK_EMPTY, WARN, metaCount, modelShort, relTime, } from '../glyphs.js';
|
|
9
9
|
import { TaskRow } from './task-row.js';
|
|
10
10
|
import { META_ROWS, ROW_ALL, ROW_SPOT } from '../model.js';
|
|
11
11
|
import { rowLabel, sessionTitle } from '../session-list.js';
|
|
@@ -94,24 +94,22 @@ export function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW
|
|
|
94
94
|
? 'nessuna conversazione nel progetto'
|
|
95
95
|
: isSpot
|
|
96
96
|
? 'nessuna sessione libera'
|
|
97
|
-
: 'nessuna sessione legata a questa task'), paneTextWidth(columns)) })) : (rows.map((row
|
|
98
|
-
// T50 — separatore leggero fra pinnate e contestuali: riga dim, non un
|
|
99
|
-
// box pesante (coerente con lo styling delle Done dimmate).
|
|
100
|
-
if (row.kind === 'separator') {
|
|
101
|
-
return (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: SESSION_SEP }, `sep${i}`));
|
|
102
|
-
}
|
|
97
|
+
: 'nessuna sessione legata a questa task'), paneTextWidth(columns)) })) : (rows.map((row) => {
|
|
103
98
|
const sel = row.sessionId === selectedId;
|
|
104
99
|
// T50 — pin stale: transcript sparito, nessuna Session da mostrare.
|
|
105
|
-
// Riga navigabile e spinnabile (`p`), marcata, mai un crash.
|
|
106
|
-
|
|
100
|
+
// Riga navigabile e spinnabile (`p`), marcata, mai un crash. T133 D5 —
|
|
101
|
+
// compare nella sola vista `📌`, dove non ha accanto nessuna griglia da
|
|
102
|
+
// rispettare: resta testo libero.
|
|
103
|
+
if (row.kind === 'stale') {
|
|
107
104
|
// T60 — anche qui la nota si taglia sul budget DERIVATO, non su un
|
|
108
105
|
// 30 inchiodato: su un pane stretto quel valore fisso mandava la
|
|
109
106
|
// riga oltre il bordo, e a ripararla arrivava `cli-truncate` (che
|
|
110
107
|
// sfora di una colonna per emoji e mangia il bordo stesso).
|
|
111
108
|
const staleNote = sessionNotes.get(row.sessionId);
|
|
112
109
|
// La riga stale è libera (niente colonne: non ha né titolo né
|
|
113
|
-
// data), ma il binding va detto lo stesso —
|
|
114
|
-
//
|
|
110
|
+
// data), ma il binding va detto lo stesso — vive nella vista `📌`,
|
|
111
|
+
// che raccoglie le pinnate di ogni task e quindi non ne dice
|
|
112
|
+
// l'appartenenza dall'header.
|
|
115
113
|
const staleTask = bindings.get(row.sessionId) ?? null;
|
|
116
114
|
const staleW = Math.max(0, paneTextWidth(columns) -
|
|
117
115
|
(2 /* caret */ +
|
|
@@ -121,8 +119,8 @@ export function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW
|
|
|
121
119
|
3 /* spazio + caporali */));
|
|
122
120
|
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: true, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { color: "yellow", children: WARN }), " pin stale", ' ', _jsx(Text, { dimColor: true, children: row.sessionId.slice(0, SID_CHARS) }), staleTask ? _jsxs(Text, { color: "green", children: [" ", staleTask] }) : null, staleNote ? _jsxs(Text, { color: "yellow", children: [" \u00AB", cut(staleNote, staleW), "\u00BB"] }) : null] }, row.sessionId));
|
|
123
121
|
}
|
|
124
|
-
const s = row.session;
|
|
125
|
-
const isPinnedRow = row.
|
|
122
|
+
const s = row.session;
|
|
123
|
+
const isPinnedRow = row.pinned;
|
|
126
124
|
// T28 — un ramo eredita il titolo dell'origine: senza marcatore le due
|
|
127
125
|
// righe sarebbero identiche a occhio.
|
|
128
126
|
const forked = forkOf.has(s.sessionId);
|
|
@@ -139,13 +137,12 @@ export function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW
|
|
|
139
137
|
// appartiene la conversazione (pin/task/spot), questa dice se è aperta
|
|
140
138
|
// adesso. Farle condividere una cella perderebbe una delle due.
|
|
141
139
|
const liveEntry = live.get(s.sessionId);
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
const taskCell = isAll || isPinnedRow ? (bound ?? TASK_EMPTY) : '';
|
|
140
|
+
// T133 D10 — la colonna vive nella sola vista "tutte". Il ramo sulle
|
|
141
|
+
// pinnate esisteva perché una pinnata stava in lista anche sotto un
|
|
142
|
+
// parent che non era il suo; ora ogni riga è figlia del parent
|
|
143
|
+
// selezionato, quindi l'header parla già per tutte e la cella
|
|
144
|
+
// ripeterebbe N volte ciò che è scritto una riga sopra.
|
|
145
|
+
const taskCell = isAll ? (bound ?? TASK_EMPTY) : '';
|
|
149
146
|
// T60 — colonne VERE: ogni cella fissa è larga esattamente quanto
|
|
150
147
|
// dichiara, riempita di spazi con `pad` (che misura in colonne, non in
|
|
151
148
|
// caratteri). Il marker va portato a 2 anche quando è `○`, largo 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