@lamemind/loom-deck 0.34.0 → 0.34.2
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/cli.js +308 -1973
- package/dist/glyphs.js +83 -0
- package/dist/hooks.js +192 -0
- package/dist/input-modes.js +52 -0
- package/dist/layout.js +121 -0
- package/dist/model.js +55 -0
- package/dist/overlays/assign.js +112 -0
- package/dist/overlays/search.js +259 -0
- package/dist/overlays/sheet.js +242 -0
- package/dist/spawn.js +262 -0
- package/dist/ui/assign-screen.js +40 -0
- package/dist/ui/detail-screen.js +117 -0
- package/dist/ui/modals.js +59 -0
- package/dist/ui/panes.js +220 -0
- package/dist/ui/preview.js +45 -0
- package/dist/ui/search-screen.js +115 -0
- package/dist/viewport.js +6 -5
- package/package.json +1 -1
- package/scripts/deck-run +49 -4
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// Overlay RICERCA e READER (T52): stato, derivati e input nello stesso file.
|
|
2
|
+
//
|
|
3
|
+
// I due lati stanno insieme perché sono la stessa cosa vista da due angoli — lo
|
|
4
|
+
// stato descrive cosa il modale mostra, l'handler cosa lo cambia — e separarli
|
|
5
|
+
// metterebbe ogni modifica a cavallo di due file.
|
|
6
|
+
//
|
|
7
|
+
// Reader e ricerca condividono un hook solo perché il primo è un modale DENTRO
|
|
8
|
+
// il secondo: l'occorrenza aperta nel reader è una riga della lista sottostante,
|
|
9
|
+
// e `esc` deve ritrovare quella lista esattamente com'era.
|
|
10
|
+
//
|
|
11
|
+
// Ciò che esce dal deck (il resume di una conversazione) NON sta qui: arriva
|
|
12
|
+
// come callback `onResume`. È il confine del criterio di partizione — gli
|
|
13
|
+
// effetti verso l'esterno stanno a monte, e un overlay li chiede invece di
|
|
14
|
+
// eseguirli.
|
|
15
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
16
|
+
import { buildRows, firstRowKey, moveRowSelection, rowIndexOfKey, searchSessions, selectedRow, DEFAULT_OPTIONS, } from '../search.js';
|
|
17
|
+
import { readerCapacity, searchListCapacity } from '../viewport.js';
|
|
18
|
+
import { wrapWithOffsets } from '../width.js';
|
|
19
|
+
import { searchExcerptWidth } from '../layout.js';
|
|
20
|
+
import { sanitizeTyped } from '../glyphs.js';
|
|
21
|
+
import { SEARCH_KIND_KEYS, SEARCH_TOGGLE_KEYS } from '../model.js';
|
|
22
|
+
export function useSearchOverlay(deps) {
|
|
23
|
+
const { sessions, rows, columns, hasNote, setMode, setNote, onResume } = deps;
|
|
24
|
+
// T52 — stato del modale ricerca. VOLATILE per decisione (D4): vive in
|
|
25
|
+
// memoria, quindi riaprendo il modale ritrovi query e toggle come li avevi
|
|
26
|
+
// lasciati, ma un riavvio del deck riporta ai default. Niente schema nuovo su
|
|
27
|
+
// `deck-view.json` e nessuna scrittura implicita su disco — che
|
|
28
|
+
// contraddirebbe la regola T39 «il disco si tocca solo su `w`», qui non
|
|
29
|
+
// trasportabile perché dentro il modale `w` è un carattere digitabile.
|
|
30
|
+
const [hash, setHash] = useState('');
|
|
31
|
+
const [query, setQuery] = useState('');
|
|
32
|
+
const [field, setField] = useState('query');
|
|
33
|
+
const [opts, setOpts] = useState(DEFAULT_OPTIONS);
|
|
34
|
+
const [selKey, setSelKey] = useState(null);
|
|
35
|
+
// Occorrenza aperta nel reader; null = reader chiuso. Tenerla separata dalla
|
|
36
|
+
// selezione della lista è ciò che permette a `esc` di tornare indietro
|
|
37
|
+
// trovando la lista esattamente com'era.
|
|
38
|
+
const [readerRow, setReaderRow] = useState(null);
|
|
39
|
+
const [readerTop, setReaderTop] = useState(0);
|
|
40
|
+
// T52 — ricerca EAGER: rigira a ogni carattere digitato, non su ⏎. È
|
|
41
|
+
// sostenibile perché i corpi sono già in RAM dentro la cache mtime-keyed
|
|
42
|
+
// dell'adapter (D5): misurato su questo progetto, 0,8 ms sui soli corpi IA e
|
|
43
|
+
// ≤9 ms su tutti i tipi — sotto il tempo fra due battute. Il memo evita di
|
|
44
|
+
// rifarla sui re-render che non toccano né query né opzioni (il poll delle
|
|
45
|
+
// sessioni ogni 1,5s è già filtrato dalla signature in `useSessions`).
|
|
46
|
+
const result = useMemo(() => searchSessions(sessions, hash, query, opts, searchExcerptWidth(columns)), [sessions, hash, query, opts, columns]);
|
|
47
|
+
// Con l'hash valorizzato la conversazione è una sola e già nominata nel campo:
|
|
48
|
+
// la riga-sessione ripeterebbe un dato costante rubando una riga per gruppo.
|
|
49
|
+
const flat = hash.trim().length > 0;
|
|
50
|
+
const searchRows = useMemo(() => buildRows(result, flat), [result, flat]);
|
|
51
|
+
// T52 — riga selezionata e, se è un'occorrenza, il suo corpo wrappato per
|
|
52
|
+
// l'anteprima sotto la lista. Memoizzato per (testo, larghezza): navigando
|
|
53
|
+
// con le frecce si ri-wrappa solo quando cambia davvero l'occorrenza.
|
|
54
|
+
const selRow = useMemo(() => selectedRow(searchRows, selKey), [searchRows, selKey]);
|
|
55
|
+
const previewWidth = Math.max(20, (columns || 80) - 8);
|
|
56
|
+
const previewBody = useMemo(() => (selRow?.kind === 'hit' ? wrapWithOffsets(selRow.hit.text, previewWidth) : []), [selRow, previewWidth]);
|
|
57
|
+
// T52 — corpo del messaggio aperto nel reader, wrappato UNA volta per (testo,
|
|
58
|
+
// larghezza). Senza memo ogni pressione di freccia rifarebbe l'a-capo di un
|
|
59
|
+
// messaggio che nella coda lunga arriva a 150k char.
|
|
60
|
+
const readerWidth = Math.max(20, (columns || 80) - 6);
|
|
61
|
+
const readerLines = useMemo(() => (readerRow?.kind === 'hit' ? wrapWithOffsets(readerRow.hit.text, readerWidth) : []), [readerRow, readerWidth]);
|
|
62
|
+
const readerCap = readerCapacity(rows);
|
|
63
|
+
const readerMaxTop = Math.max(0, readerLines.length - readerCap);
|
|
64
|
+
const listCap = searchListCapacity(rows, hasNote);
|
|
65
|
+
// T52 — invariante di validità della selezione, più stretta che altrove: la
|
|
66
|
+
// lista si RICOSTRUISCE a ogni carattere digitato, quindi la chiave
|
|
67
|
+
// selezionata sparisce di continuo. Persa → prima riga, mai una posizione
|
|
68
|
+
// ereditata (che qui punterebbe a un'altra conversazione, non a un'altra riga).
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (rowIndexOfKey(searchRows, selKey) < 0) {
|
|
71
|
+
setSelKey(firstRowKey(searchRows));
|
|
72
|
+
}
|
|
73
|
+
}, [searchRows, selKey]);
|
|
74
|
+
// T52 — `⏎` contestuale al TIPO di riga: la lista ne mescola due e l'azione
|
|
75
|
+
// giusta dipende da quale è selezionata. Riga sessione → resume, identico al
|
|
76
|
+
// `⏎` della lista sessioni (stessa funzione, T49). Riga occorrenza → reader.
|
|
77
|
+
function submitRow() {
|
|
78
|
+
const row = selectedRow(searchRows, selKey);
|
|
79
|
+
if (!row) {
|
|
80
|
+
setNote(result.error ? '⚠ regex non valida' : 'nessuna occorrenza selezionata');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (row.kind === 'session') {
|
|
84
|
+
onResume(row);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// D8 — il reader si apre POSIZIONATO sull'occorrenza, non in cima. Il 94%
|
|
88
|
+
// dei messaggi entra in una schermata e la differenza non si vede; è sul 6%
|
|
89
|
+
// lungo (p99 ≈ 88 righe, max ≈ 1547) che aprire in cima costringerebbe a
|
|
90
|
+
// rifare a mano la ricerca appena fatta — cioè proprio il lavoro che questa
|
|
91
|
+
// feature esiste per evitare.
|
|
92
|
+
const lines = wrapWithOffsets(row.hit.text, readerWidth);
|
|
93
|
+
const cap = readerCapacity(rows);
|
|
94
|
+
const matchLine = lines.findIndex((l) => l.end > row.hit.matchStart);
|
|
95
|
+
const maxTop = Math.max(0, lines.length - cap);
|
|
96
|
+
setReaderRow(row);
|
|
97
|
+
setReaderTop(Math.max(0, Math.min(maxTop, Math.max(0, matchLine) - Math.floor(cap / 2))));
|
|
98
|
+
setNote('');
|
|
99
|
+
setMode('reader');
|
|
100
|
+
}
|
|
101
|
+
function scrollReader(delta) {
|
|
102
|
+
setReaderTop((t) => Math.max(0, Math.min(readerMaxTop, t + delta)));
|
|
103
|
+
}
|
|
104
|
+
// T52 — toggle di tipo messaggio. Spegnerli tutti è uno stato lecito (lista
|
|
105
|
+
// vuota, nessun errore): è l'utente che ha chiuso ogni canale, non un guasto.
|
|
106
|
+
function toggleKind(kind) {
|
|
107
|
+
setOpts((o) => ({ ...o, kinds: { ...o.kinds, [kind]: !o.kinds[kind] } }));
|
|
108
|
+
}
|
|
109
|
+
function editField(fn) {
|
|
110
|
+
// La nota racconta l'esito di un'AZIONE su una lista che, con l'eager, si
|
|
111
|
+
// ricostruisce a ogni carattere: appena la query cambia è già scaduta.
|
|
112
|
+
// Lasciarla lì la fa leggere come se descrivesse lo stato corrente — nello
|
|
113
|
+
// specifico «nessuna occorrenza selezionata» sopra una lista con una riga
|
|
114
|
+
// visibilmente selezionata, cioè una contraddizione a schermo.
|
|
115
|
+
setNote('');
|
|
116
|
+
if (field === 'hash')
|
|
117
|
+
setHash(fn);
|
|
118
|
+
else
|
|
119
|
+
setQuery(fn);
|
|
120
|
+
}
|
|
121
|
+
// `useInput` consegna il CHUNK letto da stdin, non un tasto: un incollaggio —
|
|
122
|
+
// o una raffica di tasti piu' veloce di una read — arriva come stringa unica,
|
|
123
|
+
// byte di controllo compresi. Due conseguenze, entrambe verificate su pty:
|
|
124
|
+
//
|
|
125
|
+
// 1. I byte di controllo finiscono DENTRO il campo se non li si filtra. Non
|
|
126
|
+
// si vedono, ma Ink li conta nella larghezza della riga e nessun match li
|
|
127
|
+
// soddisfa -> la ricerca smette di trovare senza dire perche'. Le newline
|
|
128
|
+
// diventano spazio invece di sparire: incollare due righe deve separare
|
|
129
|
+
// le parole, non fonderle.
|
|
130
|
+
//
|
|
131
|
+
// 2. Un TAB digitato subito dopo una lettera (~60 ms, cioe' battitura veloce
|
|
132
|
+
// normale) arriva incollato ad essa: `key.tab` resta falso e il ramo del
|
|
133
|
+
// cambio campo non scatta mai. Trattarlo come testo lo renderebbe uno
|
|
134
|
+
// spazio, per giunta nel campo sbagliato — e' cosi' che `70897aff` + Tab
|
|
135
|
+
// + `congelat` finiva tutto quanto nel campo hash.
|
|
136
|
+
//
|
|
137
|
+
// Si spezza quindi il chunk SUL TAB applicando il cambio campo in mezzo: il
|
|
138
|
+
// risultato e' identico alla battitura lenta. Stessa lezione del modale sort
|
|
139
|
+
// (T39), che cicla sui caratteri del chunk invece di leggerlo intero.
|
|
140
|
+
function typeIntoField(chunk) {
|
|
141
|
+
setNote('');
|
|
142
|
+
const parts = chunk.split('\t');
|
|
143
|
+
let cur = field;
|
|
144
|
+
const add = { hash: '', query: '' };
|
|
145
|
+
for (let k = 0; k < parts.length; k++) {
|
|
146
|
+
if (k > 0)
|
|
147
|
+
cur = cur === 'hash' ? 'query' : 'hash';
|
|
148
|
+
add[cur] += sanitizeTyped(parts[k]);
|
|
149
|
+
}
|
|
150
|
+
if (add.hash)
|
|
151
|
+
setHash((v) => v + add.hash);
|
|
152
|
+
if (add.query)
|
|
153
|
+
setQuery((v) => v + add.query);
|
|
154
|
+
setField(cur);
|
|
155
|
+
}
|
|
156
|
+
/** Apertura del modale dal modo normale: `^F`. */
|
|
157
|
+
function open() {
|
|
158
|
+
setNote('');
|
|
159
|
+
setMode('search');
|
|
160
|
+
}
|
|
161
|
+
// Handler del modo `reader`. Sta prima di quello della ricerca nel catalogo di
|
|
162
|
+
// `input-modes.ts`, ma la precedenza la porta ormai il modo: `mode` è un
|
|
163
|
+
// valore solo, quindi i due non possono essere attivi insieme.
|
|
164
|
+
function onReaderKey(input, key) {
|
|
165
|
+
if (key.escape) {
|
|
166
|
+
setMode('search');
|
|
167
|
+
setReaderRow(null);
|
|
168
|
+
}
|
|
169
|
+
else if (key.upArrow) {
|
|
170
|
+
scrollReader(-1);
|
|
171
|
+
}
|
|
172
|
+
else if (key.downArrow) {
|
|
173
|
+
scrollReader(1);
|
|
174
|
+
}
|
|
175
|
+
else if (key.pageUp) {
|
|
176
|
+
scrollReader(-readerCap);
|
|
177
|
+
}
|
|
178
|
+
else if (key.pageDown) {
|
|
179
|
+
scrollReader(readerCap);
|
|
180
|
+
}
|
|
181
|
+
else if (input === 'g') {
|
|
182
|
+
// Estremi su lettera e non su Home/End: Ink RICONOSCE quelle due (le
|
|
183
|
+
// mappa a 'home'/'end' nel parser) ma NON le espone — `nonAlphanumericKeys`
|
|
184
|
+
// azzera l'input e nessun flag le rappresenta, quindi arrivano
|
|
185
|
+
// indistinguibili da qualunque tasto ignoto. Verificato su pty reale.
|
|
186
|
+
// Nel reader non c'è input di testo, quindi le lettere sono libere.
|
|
187
|
+
setReaderTop(0);
|
|
188
|
+
}
|
|
189
|
+
else if (input === 'G') {
|
|
190
|
+
setReaderTop(readerMaxTop);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// T52 — modale ricerca. I due campi di testo mangiano ogni lettera nuda:
|
|
194
|
+
// ogni comando che deve restare vivo MENTRE si digita passa da CTRL, dai
|
|
195
|
+
// tasti freccia o da Tab. `esc` chiude il modale, non il deck.
|
|
196
|
+
function onSearchKey(input, key) {
|
|
197
|
+
if (key.escape) {
|
|
198
|
+
setMode('normal');
|
|
199
|
+
setNote('');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (key.ctrl) {
|
|
203
|
+
const flag = SEARCH_TOGGLE_KEYS[input];
|
|
204
|
+
if (flag) {
|
|
205
|
+
setOpts((o) => ({ ...o, [flag]: !o[flag] }));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const kind = SEARCH_KIND_KEYS[input];
|
|
209
|
+
if (kind)
|
|
210
|
+
toggleKind(kind);
|
|
211
|
+
return; // ogni altra combo ctrl (^F incluso: siamo già dentro) = no-op
|
|
212
|
+
}
|
|
213
|
+
if (key.tab) {
|
|
214
|
+
setField((f) => (f === 'hash' ? 'query' : 'hash'));
|
|
215
|
+
}
|
|
216
|
+
else if (key.upArrow) {
|
|
217
|
+
setSelKey((k) => moveRowSelection(searchRows, k, -1));
|
|
218
|
+
}
|
|
219
|
+
else if (key.downArrow) {
|
|
220
|
+
setSelKey((k) => moveRowSelection(searchRows, k, 1));
|
|
221
|
+
}
|
|
222
|
+
else if (key.pageUp) {
|
|
223
|
+
setSelKey((k) => moveRowSelection(searchRows, k, -Math.max(1, listCap)));
|
|
224
|
+
}
|
|
225
|
+
else if (key.pageDown) {
|
|
226
|
+
setSelKey((k) => moveRowSelection(searchRows, k, Math.max(1, listCap)));
|
|
227
|
+
}
|
|
228
|
+
else if (key.return) {
|
|
229
|
+
submitRow();
|
|
230
|
+
}
|
|
231
|
+
else if (key.backspace || key.delete) {
|
|
232
|
+
editField((s) => s.slice(0, -1));
|
|
233
|
+
}
|
|
234
|
+
else if (input && !key.meta) {
|
|
235
|
+
typeIntoField(input);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
hash,
|
|
240
|
+
query,
|
|
241
|
+
field,
|
|
242
|
+
opts,
|
|
243
|
+
selKey,
|
|
244
|
+
setSelKey,
|
|
245
|
+
result,
|
|
246
|
+
rows: searchRows,
|
|
247
|
+
selRow,
|
|
248
|
+
previewBody,
|
|
249
|
+
readerRow,
|
|
250
|
+
readerTop,
|
|
251
|
+
readerLines,
|
|
252
|
+
readerCap,
|
|
253
|
+
readerMaxTop,
|
|
254
|
+
listCap,
|
|
255
|
+
open,
|
|
256
|
+
onReaderKey,
|
|
257
|
+
onSearchKey,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Overlay DETAIL della task (T66) con la sua ricerca interna (T91).
|
|
2
|
+
//
|
|
3
|
+
// Lo `sheet` è la task FOTOGRAFATA all'apertura — id, titolo e testo integrale
|
|
4
|
+
// del task file — e non una vista sulla selezione corrente: l'overlay copre la
|
|
5
|
+
// lista, quindi l'oggetto dell'azione deve restare quello che si è scelto anche
|
|
6
|
+
// se un tick del poll spostasse la selezione sotto.
|
|
7
|
+
//
|
|
8
|
+
// Si chiama `sheet` e non `detail` perché in `Deck` `detail` è già il
|
|
9
|
+
// `TaskDetail` del blocco preview sotto i pane: due cose vicine con lo stesso
|
|
10
|
+
// nome sono una trappola di lettura.
|
|
11
|
+
//
|
|
12
|
+
// Lo spawn della sessione dalla barra azioni NON sta qui: arriva come callback
|
|
13
|
+
// `onAction`, per lo stesso confine di `useSearchOverlay`.
|
|
14
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
15
|
+
import { detailCapacity } from '../viewport.js';
|
|
16
|
+
import { wrapWithOffsets } from '../width.js';
|
|
17
|
+
import { scanText, topForOffset } from '../text-search.js';
|
|
18
|
+
import { parseMarkdown } from '../markdown.js';
|
|
19
|
+
import { cpLen, insertAt, removeAt } from '../layout.js';
|
|
20
|
+
import { sanitizeTyped } from '../glyphs.js';
|
|
21
|
+
import { DETAIL_ACTIONS, MODELS, MODEL_DEFAULT } from '../spawn.js';
|
|
22
|
+
export function useSheetOverlay(deps) {
|
|
23
|
+
const { rows, columns, setMode, setNote, onAction } = deps;
|
|
24
|
+
const [sheet, setSheet] = useState(null);
|
|
25
|
+
const [top, setTop] = useState(0);
|
|
26
|
+
const [action, setAction] = useState(0);
|
|
27
|
+
// T108 — il modello con cui partirà la sessione. Stato del DETAIL e non del
|
|
28
|
+
// deck: si azzera a ogni apertura come scroll e azione, quindi non esiste una
|
|
29
|
+
// selezione invisibile che cambi il comportamento dei tasti della lista.
|
|
30
|
+
const [model, setModel] = useState(MODEL_DEFAULT);
|
|
31
|
+
// T91 — ricerca dentro il detail. `open` distingue i due modi in cui la si
|
|
32
|
+
// lascia: `esc` butta via ciò che il modale ha prodotto (`find` a null,
|
|
33
|
+
// evidenziazione via), `⏎` lo congela e restituisce il controllo allo strato
|
|
34
|
+
// sotto — campo chiuso, occorrenze ancora colorate, scroll dov'era. Senza il
|
|
35
|
+
// flag i due gesti collasserebbero su uno solo.
|
|
36
|
+
const [find, setFind] = useState(null);
|
|
37
|
+
const [occIdx, setOccIdx] = useState(0);
|
|
38
|
+
// Cornici da scalare: box esterno (2 bordi + 2 padding) + box testo (2 bordi +
|
|
39
|
+
// 2 padding) = 8. Sottostimare tronca un carattere, sovrastimare manda a capo
|
|
40
|
+
// una riga che il budget d'altezza non ha contato.
|
|
41
|
+
const width = Math.max(20, (columns || 80) - 8);
|
|
42
|
+
// T75 — il markdown si rende PRIMA del wrap, e il resto della catena lavora
|
|
43
|
+
// sul testo reso: `**foo**` occupa 3 colonne rese e 7 grezze, quindi
|
|
44
|
+
// wrappare sui marker manderebbe a capo su un conteggio che il terminale non
|
|
45
|
+
// disegna. Memo separato dal wrap perché il parse dipende solo dal testo: un
|
|
46
|
+
// resize ri-wrappa 9KB, non li ri-parsa.
|
|
47
|
+
const doc = useMemo(() => (sheet?.text ? parseMarkdown(sheet.text) : null), [sheet]);
|
|
48
|
+
// Le righe conservano i propri offset invece di essere appiattite a stringa
|
|
49
|
+
// (T66 le buttava con `.map((l) => l.text)`): è ciò che rende
|
|
50
|
+
// l'evidenziazione un'intersezione di intervalli invece di un caso speciale
|
|
51
|
+
// per il match spezzato dall'a-capo. Dopo T75 gli offset indicizzano il testo
|
|
52
|
+
// RESO, ed è l'unica coordinata coerente che resti — il sorgente non è più
|
|
53
|
+
// ciò che sta a schermo.
|
|
54
|
+
const lines = useMemo(() => (doc ? wrapWithOffsets(doc.text, width) : []), [doc, width]);
|
|
55
|
+
const capacity = detailCapacity(rows, find?.open === true);
|
|
56
|
+
const maxTop = Math.max(0, lines.length - capacity);
|
|
57
|
+
// Lo scan gira sulla STESSA stringa che si renderizza: cercare su un testo
|
|
58
|
+
// diverso da quello a schermo darebbe offset che indicizzano un altro
|
|
59
|
+
// documento, cioè un'evidenziazione spostata di N caratteri e nessun errore.
|
|
60
|
+
//
|
|
61
|
+
// Dopo T75 quella stringa è il testo RESO, non più il sorgente: si cerca ciò
|
|
62
|
+
// che si vede. Ne discende che `**` non è più cercabile — è la conseguenza
|
|
63
|
+
// voluta, perché a schermo non c'è; e `Priority`, che prima era `**Priority**`
|
|
64
|
+
// e si trovava lo stesso, continua a trovarsi.
|
|
65
|
+
const findRes = useMemo(() => (find && doc ? scanText(doc.text, find.q) : { occ: [], error: '' }), [find?.q, doc]);
|
|
66
|
+
// L'indice si clampa qui invece di essere corretto a ogni `setOccIdx`: la
|
|
67
|
+
// lista si accorcia da sola mentre si digita, e un indice fuori range vivrebbe
|
|
68
|
+
// per il tempo di un render.
|
|
69
|
+
const occCur = findRes.occ.length > 0 ? Math.min(occIdx, findRes.occ.length - 1) : -1;
|
|
70
|
+
// Salto all'occorrenza corrente, centrata. Non dipende da `top`, quindi non si
|
|
71
|
+
// auto-rilancia; dipende da `lines` e `capacity`, quindi un resize ricalcola
|
|
72
|
+
// la posizione senza toccare le occorrenze — che sono offset del sorgente e il
|
|
73
|
+
// resize non le sposta.
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
const o = occCur >= 0 ? findRes.occ[occCur] : undefined;
|
|
76
|
+
if (!o)
|
|
77
|
+
return;
|
|
78
|
+
setTop(topForOffset(lines, o.start, capacity));
|
|
79
|
+
}, [findRes, occCur, lines, capacity]);
|
|
80
|
+
/** Apre il detail su una task, azzerando scroll, azione, modello e ricerca. */
|
|
81
|
+
function open(next) {
|
|
82
|
+
setSheet(next);
|
|
83
|
+
setTop(0);
|
|
84
|
+
setAction(0);
|
|
85
|
+
setModel(MODEL_DEFAULT);
|
|
86
|
+
setFind(null);
|
|
87
|
+
setOccIdx(0);
|
|
88
|
+
setNote('');
|
|
89
|
+
setMode('detail');
|
|
90
|
+
}
|
|
91
|
+
function close() {
|
|
92
|
+
setMode('normal');
|
|
93
|
+
setSheet(null);
|
|
94
|
+
setFind(null);
|
|
95
|
+
}
|
|
96
|
+
function scroll(delta) {
|
|
97
|
+
setTop((t) => Math.max(0, Math.min(maxTop, t + delta)));
|
|
98
|
+
}
|
|
99
|
+
/** Modifica la query: l'insieme delle occorrenze cambia, quindi si riparte
|
|
100
|
+
* dalla prima. Il movimento del caret NON passa di qui — sposta il cursore,
|
|
101
|
+
* non i risultati. */
|
|
102
|
+
function editFind(next) {
|
|
103
|
+
setFind((f) => (f ? next(f) : f));
|
|
104
|
+
setOccIdx(0);
|
|
105
|
+
}
|
|
106
|
+
function moveOcc(d) {
|
|
107
|
+
const n = findRes.occ.length;
|
|
108
|
+
if (n === 0)
|
|
109
|
+
return;
|
|
110
|
+
setOccIdx((i) => (Math.min(i, n - 1) + d + n) % n);
|
|
111
|
+
}
|
|
112
|
+
// T91 — il campo di ricerca è un modale DENTRO il detail, e lo intercetta per
|
|
113
|
+
// intero: mentre è aperto mangia ogni lettera nuda, `g`/`G` compresi.
|
|
114
|
+
function onFindKey(input, key) {
|
|
115
|
+
if (key.escape) {
|
|
116
|
+
// Annulla: butta via ciò che il modale ha prodotto. Lo scroll resta dove
|
|
117
|
+
// la ricerca l'ha portato — riavvolgerlo sarebbe una terza semantica che
|
|
118
|
+
// nessun tasto ha chiesto.
|
|
119
|
+
setFind(null);
|
|
120
|
+
}
|
|
121
|
+
else if (key.return) {
|
|
122
|
+
// Congela: campo chiuso, occorrenze ancora colorate, scroll intatto.
|
|
123
|
+
setFind((f) => (f ? { ...f, open: false } : f));
|
|
124
|
+
}
|
|
125
|
+
else if (key.upArrow) {
|
|
126
|
+
moveOcc(-1);
|
|
127
|
+
}
|
|
128
|
+
else if (key.downArrow) {
|
|
129
|
+
moveOcc(1);
|
|
130
|
+
}
|
|
131
|
+
else if (key.leftArrow || key.rightArrow) {
|
|
132
|
+
const d = key.leftArrow ? -1 : 1;
|
|
133
|
+
setFind((f) => (f ? { ...f, caret: Math.max(0, Math.min(cpLen(f.q), f.caret + d)) } : f));
|
|
134
|
+
}
|
|
135
|
+
else if (key.backspace || key.delete) {
|
|
136
|
+
editFind((f) => f.caret > 0 ? { ...f, q: removeAt(f.q, f.caret - 1), caret: f.caret - 1 } : f);
|
|
137
|
+
}
|
|
138
|
+
else if (key.ctrl) {
|
|
139
|
+
// `^U` svuota, come il filtro del modale assegnazione; ogni altra combo
|
|
140
|
+
// è no-op — `^F` incluso, siamo già dentro.
|
|
141
|
+
if (input === 'u')
|
|
142
|
+
editFind((f) => ({ ...f, q: '', caret: 0 }));
|
|
143
|
+
}
|
|
144
|
+
else if (input && !key.meta) {
|
|
145
|
+
const ins = sanitizeTyped(input);
|
|
146
|
+
editFind((f) => ({ ...f, q: insertAt(f.q, f.caret, ins), caret: f.caret + cpLen(ins) }));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// T66 — detail della task: due zone (testo scrollabile + barra azioni) e una
|
|
150
|
+
// selezione per ognuna. Il modo cattura TUTTO, acceleratori `^K`/`^P`/`^R`
|
|
151
|
+
// compresi: chi è già nel detail ha i bottoni. L'unica deroga è `^F`, che qui
|
|
152
|
+
// apre la ricerca nel testo invece di quella sulle conversazioni.
|
|
153
|
+
function onKey(input, key) {
|
|
154
|
+
if (find?.open) {
|
|
155
|
+
onFindKey(input, key);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (key.ctrl && input === 'f') {
|
|
159
|
+
// Fuori dal detail `^F` è la ricerca conversazioni; qui è la ricerca nel
|
|
160
|
+
// testo. La query sopravvive a una chiusura con `⏎`, quindi riaprire
|
|
161
|
+
// riprende da dov'era invece di ricominciare.
|
|
162
|
+
setFind((f) => (f ? { ...f, open: true } : { q: '', caret: 0, open: true }));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (key.escape) {
|
|
166
|
+
// Uno strato alla volta: se resta un'evidenziazione congelata, `esc`
|
|
167
|
+
// smonta quella; il detail lo chiude il secondo.
|
|
168
|
+
// Nessun reset di `sel`: la selezione della lista non è mai stata
|
|
169
|
+
// toccata, quindi si ritrova esattamente dov'era.
|
|
170
|
+
if (find)
|
|
171
|
+
setFind(null);
|
|
172
|
+
else
|
|
173
|
+
close();
|
|
174
|
+
}
|
|
175
|
+
else if (key.return) {
|
|
176
|
+
// `⏎` esegue SEMPRE l'azione selezionata, mai "scrolla" o "chiudi": il
|
|
177
|
+
// testo non è un campo attivo (si scorre, non si edita), quindi nessuna
|
|
178
|
+
// competizione sul tasto.
|
|
179
|
+
const act = DETAIL_ACTIONS[action];
|
|
180
|
+
const id = sheet?.id;
|
|
181
|
+
close();
|
|
182
|
+
if (id)
|
|
183
|
+
onAction(id, act.kind, model, `⏎ ${act.label}`);
|
|
184
|
+
}
|
|
185
|
+
else if (key.tab) {
|
|
186
|
+
// T108 — `tab` scorre il catalogo dei modelli. Libero solo QUI: in vista
|
|
187
|
+
// normale cicla la vista del pane a fuoco, e le cifre `1`-`9` sono della
|
|
188
|
+
// legenda launch. Il detail cattura l'input per intero, quindi è dove un
|
|
189
|
+
// alfabeto già speso torna disponibile.
|
|
190
|
+
setModel((m) => MODELS[(MODELS.indexOf(m) + 1) % MODELS.length]);
|
|
191
|
+
}
|
|
192
|
+
else if (input.length === 1 && input >= '1' && input <= String(MODELS.length)) {
|
|
193
|
+
// Scelta diretta: chi sa già quale vuole non paga lo scorrimento. Il test
|
|
194
|
+
// sulla lunghezza non è pleonastico: `useInput` consegna il CHUNK letto da
|
|
195
|
+
// stdin, quindi un incollato come `12` passerebbe il confronto fra
|
|
196
|
+
// stringhe e indicizzerebbe il catalogo fuori range.
|
|
197
|
+
setModel(MODELS[Number(input) - 1]);
|
|
198
|
+
}
|
|
199
|
+
else if (key.leftArrow || key.rightArrow) {
|
|
200
|
+
// Scorrimento CICLICO come le righe di scelta del modale edit: cinque
|
|
201
|
+
// voci, arrivare in fondo e ripartire costa meno che invertire direzione.
|
|
202
|
+
const d = key.leftArrow ? -1 : 1;
|
|
203
|
+
setAction((i) => (i + d + DETAIL_ACTIONS.length) % DETAIL_ACTIONS.length);
|
|
204
|
+
}
|
|
205
|
+
else if (key.upArrow) {
|
|
206
|
+
scroll(-1);
|
|
207
|
+
}
|
|
208
|
+
else if (key.downArrow) {
|
|
209
|
+
scroll(1);
|
|
210
|
+
}
|
|
211
|
+
else if (key.pageUp) {
|
|
212
|
+
scroll(-capacity);
|
|
213
|
+
}
|
|
214
|
+
else if (key.pageDown) {
|
|
215
|
+
scroll(capacity);
|
|
216
|
+
}
|
|
217
|
+
else if (input === 'g') {
|
|
218
|
+
// Estremi su lettera per lo stesso motivo del reader: Ink riconosce
|
|
219
|
+
// `Home`/`End` ma non le espone, e qui non c'è input di testo che
|
|
220
|
+
// contenda le lettere.
|
|
221
|
+
setTop(0);
|
|
222
|
+
}
|
|
223
|
+
else if (input === 'G') {
|
|
224
|
+
setTop(maxTop);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
sheet,
|
|
229
|
+
doc,
|
|
230
|
+
top,
|
|
231
|
+
action,
|
|
232
|
+
model,
|
|
233
|
+
find,
|
|
234
|
+
lines,
|
|
235
|
+
capacity,
|
|
236
|
+
maxTop,
|
|
237
|
+
findRes,
|
|
238
|
+
occCur,
|
|
239
|
+
open,
|
|
240
|
+
onKey,
|
|
241
|
+
};
|
|
242
|
+
}
|