@lamemind/loom-deck 0.29.0 → 0.30.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/cli.js +132 -18
- package/dist/search.js +2 -2
- package/dist/text-search.js +100 -0
- package/dist/viewport.js +14 -3
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -18,6 +18,7 @@ import { cellWidth, launchLegend, loadArchivableDays, loadIdentity, loadLaunch,
|
|
|
18
18
|
import { countArchivable, SCAN_INTERVAL_MS } from './archivable.js';
|
|
19
19
|
import { assignListCapacity, detailCapacity, isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
|
|
20
20
|
import { caretWindow, cut, cutParts, pad, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
|
|
21
|
+
import { scanText, sliceLine, topForOffset, } from './text-search.js';
|
|
21
22
|
import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
22
23
|
import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
|
|
23
24
|
import { loadView, saveView, viewFilePath } from './view-store.js';
|
|
@@ -665,6 +666,13 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
665
666
|
const [sheet, setSheet] = useState(null);
|
|
666
667
|
const [sheetTop, setSheetTop] = useState(0);
|
|
667
668
|
const [sheetAction, setSheetAction] = useState(0);
|
|
669
|
+
// T91 — ricerca dentro il detail. `open` distingue i due modi in cui la si
|
|
670
|
+
// lascia: `esc` butta via ciò che il modale ha prodotto (`find` a null,
|
|
671
|
+
// evidenziazione via), `⏎` lo congela e restituisce il controllo allo strato
|
|
672
|
+
// sotto — campo chiuso, occorrenze ancora colorate, scroll dov'era. Senza il
|
|
673
|
+
// flag i due gesti collasserebbero su uno solo.
|
|
674
|
+
const [find, setFind] = useState(null);
|
|
675
|
+
const [occIdx, setOccIdx] = useState(0);
|
|
668
676
|
// Dimensioni vive del terminale: sono l'input del budget d'altezza sotto.
|
|
669
677
|
const { rows, columns } = useTerminalSize();
|
|
670
678
|
// Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
|
|
@@ -792,9 +800,32 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
792
800
|
// 2 padding) = 8. Sottostimare tronca un carattere, sovrastimare manda a capo
|
|
793
801
|
// una riga che il budget d'altezza non ha contato.
|
|
794
802
|
const sheetWidth = Math.max(20, (columns || 80) - 8);
|
|
795
|
-
|
|
796
|
-
|
|
803
|
+
// Le righe conservano i propri offset invece di essere appiattite a stringa
|
|
804
|
+
// (T66 le buttava con `.map((l) => l.text)`): è ciò che rende
|
|
805
|
+
// l'evidenziazione un'intersezione di intervalli invece di un caso speciale
|
|
806
|
+
// per il match spezzato dall'a-capo.
|
|
807
|
+
const sheetLines = useMemo(() => (sheet?.text ? wrapWithOffsets(sheet.text, sheetWidth) : []), [sheet, sheetWidth]);
|
|
808
|
+
const sheetCap = detailCapacity(rows, find?.open === true);
|
|
797
809
|
const sheetMaxTop = Math.max(0, sheetLines.length - sheetCap);
|
|
810
|
+
// Lo scan gira sulla STESSA stringa che si renderizza (`sheet.text`, già
|
|
811
|
+
// passata da `sanitize` in `loadTaskFileText`): una rilettura del file darebbe
|
|
812
|
+
// offset che indicizzano un documento diverso da quello a schermo, cioè
|
|
813
|
+
// un'evidenziazione spostata di N caratteri e nessun errore.
|
|
814
|
+
const findRes = useMemo(() => (find && sheet?.text ? scanText(sheet.text, find.q) : { occ: [], error: '' }), [find?.q, sheet]);
|
|
815
|
+
// L'indice si clampa qui invece di essere corretto a ogni `setOccIdx`: la
|
|
816
|
+
// lista si accorcia da sola mentre si digita, e un indice fuori range vivrebbe
|
|
817
|
+
// per il tempo di un render.
|
|
818
|
+
const occCur = findRes.occ.length > 0 ? Math.min(occIdx, findRes.occ.length - 1) : -1;
|
|
819
|
+
// Salto all'occorrenza corrente, centrata. Non dipende da `sheetTop`, quindi
|
|
820
|
+
// non si auto-rilancia; dipende da `sheetLines` e `sheetCap`, quindi un resize
|
|
821
|
+
// ricalcola la posizione senza toccare le occorrenze — che sono offset del
|
|
822
|
+
// sorgente e il resize non le sposta.
|
|
823
|
+
useEffect(() => {
|
|
824
|
+
const o = occCur >= 0 ? findRes.occ[occCur] : undefined;
|
|
825
|
+
if (!o)
|
|
826
|
+
return;
|
|
827
|
+
setSheetTop(topForOffset(sheetLines, o.start, sheetCap));
|
|
828
|
+
}, [findRes, occCur, sheetLines, sheetCap]);
|
|
798
829
|
// T57 — righe del modale assegnazione: `null` (detach) in testa, poi le task
|
|
799
830
|
// della VISTA corrente (D4 — filtri e sort inclusi, coerenza con ciò che si
|
|
800
831
|
// stava leggendo a sinistra; le escluse restano contate nell'header).
|
|
@@ -954,16 +985,32 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
954
985
|
});
|
|
955
986
|
setSheetTop(0);
|
|
956
987
|
setSheetAction(0);
|
|
988
|
+
setFind(null);
|
|
989
|
+
setOccIdx(0);
|
|
957
990
|
setNote('');
|
|
958
991
|
setMode('detail');
|
|
959
992
|
}
|
|
960
993
|
function closeDetail() {
|
|
961
994
|
setMode('normal');
|
|
962
995
|
setSheet(null);
|
|
996
|
+
setFind(null);
|
|
963
997
|
}
|
|
964
998
|
function scrollDetail(delta) {
|
|
965
999
|
setSheetTop((t) => Math.max(0, Math.min(sheetMaxTop, t + delta)));
|
|
966
1000
|
}
|
|
1001
|
+
/** Modifica la query: l'insieme delle occorrenze cambia, quindi si riparte
|
|
1002
|
+
* dalla prima. Il movimento del caret NON passa di qui — sposta il cursore,
|
|
1003
|
+
* non i risultati. */
|
|
1004
|
+
function editFind(next) {
|
|
1005
|
+
setFind((f) => (f ? next(f) : f));
|
|
1006
|
+
setOccIdx(0);
|
|
1007
|
+
}
|
|
1008
|
+
function moveOcc(d) {
|
|
1009
|
+
const n = findRes.occ.length;
|
|
1010
|
+
if (n === 0)
|
|
1011
|
+
return;
|
|
1012
|
+
setOccIdx((i) => (Math.min(i, n - 1) + d + n) % n);
|
|
1013
|
+
}
|
|
967
1014
|
// T53 — apertura del modale nota sulla conversazione selezionata. Come
|
|
968
1015
|
// `openEdit`, la bozza parte dal valore ATTUALE: annotare una seconda volta è
|
|
969
1016
|
// quasi sempre correggere, e ripartire da vuoto costringerebbe a ridigitare
|
|
@@ -1551,10 +1598,62 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1551
1598
|
// È anche prima del ramo CTRL, quindi `^K`/`^P`/`^R` restano acceleratori
|
|
1552
1599
|
// della sola lista: chi è già nel detail ha i bottoni.
|
|
1553
1600
|
if (mode === 'detail') {
|
|
1601
|
+
// T91 — modale DENTRO il modale, e sta prima per la stessa ragione per cui
|
|
1602
|
+
// il detail sta prima del ramo CTRL: Ink non ha focus-trap, quindi la
|
|
1603
|
+
// cattura È l'ordine dei rami. Mentre il campo è aperto mangia ogni lettera
|
|
1604
|
+
// nuda, `g`/`G` compresi.
|
|
1605
|
+
if (find?.open) {
|
|
1606
|
+
if (key.escape) {
|
|
1607
|
+
// Annulla: butta via ciò che il modale ha prodotto. Lo scroll resta
|
|
1608
|
+
// dove la ricerca l'ha portato — riavvolgerlo sarebbe una terza
|
|
1609
|
+
// semantica che nessun tasto ha chiesto.
|
|
1610
|
+
setFind(null);
|
|
1611
|
+
}
|
|
1612
|
+
else if (key.return) {
|
|
1613
|
+
// Congela: campo chiuso, occorrenze ancora colorate, scroll intatto.
|
|
1614
|
+
setFind((f) => (f ? { ...f, open: false } : f));
|
|
1615
|
+
}
|
|
1616
|
+
else if (key.upArrow) {
|
|
1617
|
+
moveOcc(-1);
|
|
1618
|
+
}
|
|
1619
|
+
else if (key.downArrow) {
|
|
1620
|
+
moveOcc(1);
|
|
1621
|
+
}
|
|
1622
|
+
else if (key.leftArrow || key.rightArrow) {
|
|
1623
|
+
const d = key.leftArrow ? -1 : 1;
|
|
1624
|
+
setFind((f) => f ? { ...f, caret: Math.max(0, Math.min(cpLen(f.q), f.caret + d)) } : f);
|
|
1625
|
+
}
|
|
1626
|
+
else if (key.backspace || key.delete) {
|
|
1627
|
+
editFind((f) => f.caret > 0 ? { ...f, q: removeAt(f.q, f.caret - 1), caret: f.caret - 1 } : f);
|
|
1628
|
+
}
|
|
1629
|
+
else if (key.ctrl) {
|
|
1630
|
+
// `^U` svuota, come il filtro del modale assegnazione; ogni altra combo
|
|
1631
|
+
// è no-op — `^F` incluso, siamo già dentro.
|
|
1632
|
+
if (input === 'u')
|
|
1633
|
+
editFind((f) => ({ ...f, q: '', caret: 0 }));
|
|
1634
|
+
}
|
|
1635
|
+
else if (input && !key.meta) {
|
|
1636
|
+
const ins = sanitizeTyped(input);
|
|
1637
|
+
editFind((f) => ({ ...f, q: insertAt(f.q, f.caret, ins), caret: f.caret + cpLen(ins) }));
|
|
1638
|
+
}
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
if (key.ctrl && input === 'f') {
|
|
1642
|
+
// Fuori dal detail `^F` è la ricerca conversazioni; qui è la ricerca nel
|
|
1643
|
+
// testo. La query sopravvive a una chiusura con `⏎`, quindi riaprire
|
|
1644
|
+
// riprende da dov'era invece di ricominciare.
|
|
1645
|
+
setFind((f) => (f ? { ...f, open: true } : { q: '', caret: 0, open: true }));
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1554
1648
|
if (key.escape) {
|
|
1649
|
+
// Uno strato alla volta: se resta un'evidenziazione congelata, `esc`
|
|
1650
|
+
// smonta quella; il detail lo chiude il secondo.
|
|
1555
1651
|
// Nessun reset di `sel`: la selezione della lista non è mai stata
|
|
1556
1652
|
// toccata, quindi si ritrova esattamente dov'era.
|
|
1557
|
-
|
|
1653
|
+
if (find)
|
|
1654
|
+
setFind(null);
|
|
1655
|
+
else
|
|
1656
|
+
closeDetail();
|
|
1558
1657
|
}
|
|
1559
1658
|
else if (key.return) {
|
|
1560
1659
|
// `⏎` esegue SEMPRE l'azione selezionata, mai "scrolla" o "chiudi": il
|
|
@@ -1875,7 +1974,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1875
1974
|
// posizione è lo scroll mosso a mano. Il clamp serve comunque — un resize
|
|
1876
1975
|
// può accorciare il testo sotto uno scroll già dato.
|
|
1877
1976
|
const start = Math.min(sheetTop, sheetMaxTop);
|
|
1878
|
-
return (_jsx(DetailScreen, { id: sheet.id, title: sheet.title, missing: sheet.text === null, lines: sheetLines.slice(start, start + sheetCap), top: start, total: sheetLines.length, capacity: sheetCap, action: sheetAction, columns: columns }));
|
|
1977
|
+
return (_jsx(DetailScreen, { id: sheet.id, title: sheet.title, missing: sheet.text === null, lines: sheetLines.slice(start, start + sheetCap), top: start, total: sheetLines.length, capacity: sheetCap, action: sheetAction, columns: columns, find: find, occ: findRes.occ, occCur: occCur }));
|
|
1879
1978
|
}
|
|
1880
1979
|
// ── T52 · schermate sostitutive ─────────────────────────────────────────
|
|
1881
1980
|
// Ricerca e reader sono gli unici modali che NON stanno in flusso sopra i
|
|
@@ -2133,7 +2232,8 @@ function SearchScreen({ preview, hash, query, field, opts, result, rows, selecte
|
|
|
2133
2232
|
*/
|
|
2134
2233
|
function SearchPreviewPane({ p }) {
|
|
2135
2234
|
const last = Math.min(p.total, p.from + p.lines.length);
|
|
2136
|
-
|
|
2235
|
+
const occ = [{ start: p.hit.matchStart, end: p.hit.matchEnd }];
|
|
2236
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["record ", p.hit.idx, " \u00B7 ", KIND_LABEL[p.hit.kind], p.ts ? ` · ${fmtDateTime(p.ts)}` : '', " \u00B7 righe ", p.from + 1, "-", last, " di ", p.total, " \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " apre il reader"] }), p.lines.map((l, i) => (_jsx(ReaderLine, { line: l, occ: occ, current: 0 }, p.from + i)))] }));
|
|
2137
2237
|
}
|
|
2138
2238
|
/**
|
|
2139
2239
|
* Reader fullscreen (T52 · D8).
|
|
@@ -2145,18 +2245,32 @@ function SearchPreviewPane({ p }) {
|
|
|
2145
2245
|
*/
|
|
2146
2246
|
function ReaderScreen({ hit, lines, top, total, capacity, bound, }) {
|
|
2147
2247
|
const last = Math.min(total, top + capacity);
|
|
2148
|
-
|
|
2248
|
+
const occ = [{ start: hit.matchStart, end: hit.matchEnd }];
|
|
2249
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["reader \u00B7 ", _jsx(Text, { color: "cyan", children: hit.sessionId.slice(0, 8) }), " \u00B7 record ", hit.idx, " \u00B7", ' ', KIND_LABEL[hit.kind], bound ? ` · ${bound}` : '', " \u00B7 righe ", total === 0 ? 0 : top + 1, "-", last, " di ", total] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " riga \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " pagina \u00B7", ' ', _jsx(Text, { color: "yellow", children: "g" }), " inizio \u00B7 ", _jsx(Text, { color: "yellow", children: "G" }), " fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " torna alla lista"] }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: lines.map((l, i) => (_jsx(ReaderLine, { line: l, occ: occ, current: 0 }, top + i))) })] }));
|
|
2149
2250
|
}
|
|
2150
|
-
/** Una riga
|
|
2151
|
-
*
|
|
2152
|
-
*
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2251
|
+
/** Una riga con le porzioni di match evidenziate. Gli offset sono quelli del
|
|
2252
|
+
* testo sorgente, quindi un match a cavallo dell'a-capo si colora su entrambe
|
|
2253
|
+
* le righe senza casi speciali — entrambe intersecano il suo intervallo.
|
|
2254
|
+
*
|
|
2255
|
+
* Regge N occorrenze perché il detail (T91) ne mostra tutte quelle visibili; il
|
|
2256
|
+
* reader (T52) ne passa una sola, che è il caso degenere dello stesso taglio. */
|
|
2257
|
+
function ReaderLine({ line, occ, current, }) {
|
|
2258
|
+
const segs = sliceLine(line.text, line.start, occ, current);
|
|
2259
|
+
if (segs.length === 0)
|
|
2157
2260
|
return _jsx(Text, { wrap: "truncate-end", children: line.text || ' ' });
|
|
2158
|
-
|
|
2159
|
-
|
|
2261
|
+
return (_jsx(Text, { wrap: "truncate-end", children: segs.map((s, i) => s.hit ? (
|
|
2262
|
+
// La corrente si distingue dalle altre per COLORE di sfondo, non per
|
|
2263
|
+
// presenza: tutte restano visibili, o navigare fra occorrenze non
|
|
2264
|
+
// mostrerebbe più dove sono le altre.
|
|
2265
|
+
_jsx(Text, { backgroundColor: s.current ? 'cyan' : 'yellow', color: "black", children: s.text }, i)) : (_jsx(Text, { children: s.text }, i))) }));
|
|
2266
|
+
}
|
|
2267
|
+
/** Campo della ricerca nel detail: finestra ancorata al caret, cursore inverso
|
|
2268
|
+
* sulla cella reale. Gemello di `EditTextField` senza la label, che qui sta
|
|
2269
|
+
* fuori perché il campo vive in FLUSSO su una riga condivisa col contatore —
|
|
2270
|
+
* non su una riga propria. */
|
|
2271
|
+
function DetailFindField({ value, caret, cols }) {
|
|
2272
|
+
const win = caretWindow(value, caret, cols);
|
|
2273
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { children: sanitize(win.head) }), _jsx(Text, { inverse: true, children: sanitize(win.at) }), _jsx(Text, { children: sanitize(win.tail) })] }));
|
|
2160
2274
|
}
|
|
2161
2275
|
/**
|
|
2162
2276
|
* Detail della task (T66): il task file scrollabile più la barra azioni.
|
|
@@ -2171,7 +2285,7 @@ function ReaderLine({ line, from, to }) {
|
|
|
2171
2285
|
* all'arrivo del mouse (T21 · SGR enable + hit-test) senza migrazione. La
|
|
2172
2286
|
* navigazione da tastiera ci si sovrappone senza conflitti.
|
|
2173
2287
|
*/
|
|
2174
|
-
function DetailScreen({ id, title, missing, lines, top, total, capacity, action, columns, }) {
|
|
2288
|
+
function DetailScreen({ id, title, missing, lines, top, total, capacity, action, columns, find, occ, occCur, }) {
|
|
2175
2289
|
const last = Math.min(total, top + capacity);
|
|
2176
2290
|
// Il taglio lo fa il chiamante (invariante ③ di width.ts): la riga bottoni è
|
|
2177
2291
|
// ASCII, quindi `truncate-end` oggi darebbe il risultato giusto per caso — ma
|
|
@@ -2192,10 +2306,10 @@ function DetailScreen({ id, title, missing, lines, top, total, capacity, action,
|
|
|
2192
2306
|
if (dropped(shown) > 0)
|
|
2193
2307
|
shown = cutParts(parts, Math.max(0, width - 6));
|
|
2194
2308
|
const cutCount = dropped(shown);
|
|
2195
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: id }), " \u00B7 ", cut(title, Math.max(10, width - 34)), missing ? '' : ` · righe ${total === 0 ? 0 : top + 1}-${last} di ${total}`] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " riga \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " pagina \u00B7", ' ', _jsx(Text, { color: "yellow", children: "g/G" }), " estremi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " azione \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " esegui \u00B7
|
|
2309
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: id }), " \u00B7 ", cut(title, Math.max(10, width - 34)), missing ? '' : ` · righe ${total === 0 ? 0 : top + 1}-${last} di ${total}`] }), find?.open ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " occorrenza \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " caret \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " tieni \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " riga \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " pagina \u00B7", ' ', _jsx(Text, { color: "yellow", children: "g/G" }), " estremi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " azione \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^F" }), " cerca \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " esegui \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " chiudi"] })), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: missing ? (_jsxs(Text, { color: "yellow", wrap: "truncate-end", children: [WARN, " task file non trovato \u00B7 le azioni restano attive (deck-run risolve la task per id)"] })) : (
|
|
2196
2310
|
// Riga vuota → uno spazio: un `<Text>` senza contenuto Ink non lo
|
|
2197
2311
|
// disegna, e il testo si compatterebbe perdendo la struttura del file.
|
|
2198
|
-
lines.map((l, i) => (_jsx(Text, { wrap: "truncate-end", children:
|
|
2312
|
+
lines.map((l, i) => _jsx(ReaderLine, { line: l, occ: occ, current: occCur }, top + i))) }), find?.open ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "cerca " }), _jsx(DetailFindField, { value: find.q, caret: find.caret, cols: Math.max(10, width - 28) }), find.q.length === 0 ? (_jsx(Text, { dimColor: true, children: " \u00B7 digita per cercare" })) : occ.length === 0 ? (_jsx(Text, { color: "yellow", children: " \u00B7 nessuna occorrenza" })) : (_jsxs(Text, { color: "cyan", children: [' ', "\u00B7 ", occCur + 1, "/", occ.length] }))] }) })) : null, _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [shown.map((part, i) => i % 2 === 1 ? (_jsx(Text, { children: part }, i)) : (_jsx(Text, { inverse: i / 2 === action, color: i / 2 === action ? 'green' : 'gray', children: part }, i))), cutCount > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", cutCount] }) : null] }) })] }));
|
|
2199
2313
|
}
|
|
2200
2314
|
/**
|
|
2201
2315
|
* Larghezza del testo dentro la lista della schermata di assegnazione: box
|
package/dist/search.js
CHANGED
|
@@ -58,8 +58,8 @@ function escapeLiteral(s) {
|
|
|
58
58
|
* `(`) è la NORMA durante la digitazione, non un caso limite. Deve degradare a
|
|
59
59
|
* lista vuota con segnalazione inline, mai a un crash della TUI.
|
|
60
60
|
*/
|
|
61
|
-
export function buildMatcher(query, opts) {
|
|
62
|
-
if (query.length <
|
|
61
|
+
export function buildMatcher(query, opts, min = MIN_QUERY) {
|
|
62
|
+
if (query.length < min)
|
|
63
63
|
return { re: null, error: '' };
|
|
64
64
|
let source = opts.regex ? query : escapeLiteral(query);
|
|
65
65
|
// Il gruppo non-catturante è obbligatorio: senza, `\b` si legherebbe al solo
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ricerca dentro UN documento: occorrenze come offset del testo sorgente.
|
|
3
|
+
*
|
|
4
|
+
* Gemello di `search.ts` per il motore, opposto per forma. Lì il corpus è N
|
|
5
|
+
* conversazioni, quindi il risultato raggruppa per sessione, cappa le occorrenze
|
|
6
|
+
* e produce estratti; qui il corpus è una stringa sola e la risposta utile è la
|
|
7
|
+
* posizione, non l'estratto — il testo è già a schermo.
|
|
8
|
+
*
|
|
9
|
+
* Gli offset sono quelli del SORGENTE, mai coordinate riga/colonna del testo
|
|
10
|
+
* wrappato. Due proprietà ne discendono, ed è il motivo per cui il modulo esiste:
|
|
11
|
+
*
|
|
12
|
+
* - **Immunità al re-wrap.** Un resize cambia larghezza e numero di righe ma non
|
|
13
|
+
* sposta un match, perché il sorgente non cambia. Coordinate «riga 42,
|
|
14
|
+
* colonna 7» andrebbero ricalcolate a ogni SIGWINCH.
|
|
15
|
+
* - **Match a cavallo dell'a-capo senza casi speciali.** Ogni riga di
|
|
16
|
+
* `wrapWithOffsets` è una fetta contigua del sorgente, quindi evidenziare è
|
|
17
|
+
* intersecare due intervalli — e un match spezzato su due righe si colora su
|
|
18
|
+
* entrambe perché entrambe intersecano.
|
|
19
|
+
*
|
|
20
|
+
* Puro: nessun Ink, nessun terminale, nessun filesystem.
|
|
21
|
+
*/
|
|
22
|
+
import { buildMatcher } from './search.js';
|
|
23
|
+
export const LITERAL = { regex: false, caseSensitive: false, wholeWord: false };
|
|
24
|
+
/**
|
|
25
|
+
* Minimo di caratteri più basso di `MIN_QUERY`.
|
|
26
|
+
*
|
|
27
|
+
* Su N conversazioni una query di 2 caratteri produce migliaia di occorrenze
|
|
28
|
+
* inutili, e la soglia a 3 protegge dall'indicizzazione a vuoto. Su un task file
|
|
29
|
+
* `T7` o `⏎` sono query legittime e frequenti: la soglia lì non protegge da
|
|
30
|
+
* niente, impedisce soltanto.
|
|
31
|
+
*/
|
|
32
|
+
export const MIN_DETAIL_QUERY = 1;
|
|
33
|
+
const EMPTY = { occ: [], error: '' };
|
|
34
|
+
/**
|
|
35
|
+
* Tutte le occorrenze di `query` in `text`, in ordine di posizione.
|
|
36
|
+
*
|
|
37
|
+
* Il testo deve essere **la stessa stringa che si renderizza**: passa da
|
|
38
|
+
* `sanitize` al confine di caricamento, e cercare su una rilettura del file
|
|
39
|
+
* produrrebbe offset che indicizzano un documento diverso da quello a schermo —
|
|
40
|
+
* evidenziazione spostata di N caratteri, senza nessun errore.
|
|
41
|
+
*/
|
|
42
|
+
export function scanText(text, query, opts = LITERAL) {
|
|
43
|
+
const { re, error } = buildMatcher(query, opts, MIN_DETAIL_QUERY);
|
|
44
|
+
if (!re)
|
|
45
|
+
return error ? { occ: [], error } : EMPTY;
|
|
46
|
+
const occ = [];
|
|
47
|
+
re.lastIndex = 0;
|
|
48
|
+
let m;
|
|
49
|
+
while ((m = re.exec(text)) !== null) {
|
|
50
|
+
// Una regex può matchare la STRINGA VUOTA (`a*`, `^`, `\b`): `exec` non
|
|
51
|
+
// avanzerebbe `lastIndex` e il ciclo non terminerebbe mai. Un match di
|
|
52
|
+
// larghezza zero non è nemmeno evidenziabile, quindi si salta invece di
|
|
53
|
+
// registrarlo.
|
|
54
|
+
if (m[0].length === 0) {
|
|
55
|
+
re.lastIndex++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
occ.push({ start: m.index, end: m.index + m[0].length });
|
|
59
|
+
}
|
|
60
|
+
return { occ, error };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Taglia una riga wrappata nei pezzi da colorare, intersecando gli offset.
|
|
64
|
+
*
|
|
65
|
+
* `occ` è in ordine e senza sovrapposizioni (`exec` avanza monotono), quindi
|
|
66
|
+
* basta una passata. Il guard `a < pos` regge comunque il caso di intervalli
|
|
67
|
+
* sovrapposti passati da un chiamante futuro, senza produrre testo duplicato.
|
|
68
|
+
*/
|
|
69
|
+
export function sliceLine(lineText, lineStart, occ, current) {
|
|
70
|
+
const out = [];
|
|
71
|
+
let pos = 0;
|
|
72
|
+
for (let i = 0; i < occ.length; i++) {
|
|
73
|
+
const a = Math.max(0, Math.min(lineText.length, occ[i].start - lineStart));
|
|
74
|
+
const b = Math.max(0, Math.min(lineText.length, occ[i].end - lineStart));
|
|
75
|
+
if (b <= a || a < pos)
|
|
76
|
+
continue;
|
|
77
|
+
if (a > pos)
|
|
78
|
+
out.push({ text: lineText.slice(pos, a), hit: false, current: false });
|
|
79
|
+
out.push({ text: lineText.slice(a, b), hit: true, current: i === current });
|
|
80
|
+
pos = b;
|
|
81
|
+
}
|
|
82
|
+
if (pos < lineText.length) {
|
|
83
|
+
out.push({ text: lineText.slice(pos), hit: false, current: false });
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Scroll che porta l'offset al CENTRO della finestra visibile.
|
|
89
|
+
*
|
|
90
|
+
* Stessa ricetta di `submitSearchRow` (T52). Centrare conta più che sembri:
|
|
91
|
+
* un'occorrenza portata in cima alla finestra arriva senza il contesto che la
|
|
92
|
+
* precede, ed è il contesto che dice se è quella giusta.
|
|
93
|
+
*/
|
|
94
|
+
export function topForOffset(lines, offset, capacity) {
|
|
95
|
+
const maxTop = Math.max(0, lines.length - capacity);
|
|
96
|
+
let i = lines.findIndex((l) => l.end > offset);
|
|
97
|
+
if (i < 0)
|
|
98
|
+
i = Math.max(0, lines.length - 1);
|
|
99
|
+
return Math.max(0, Math.min(maxTop, i - Math.floor(capacity / 2)));
|
|
100
|
+
}
|
package/dist/viewport.js
CHANGED
|
@@ -224,9 +224,20 @@ export function readerCapacity(rows) {
|
|
|
224
224
|
// aggiunta va scalata dalla capienza del contenuto o il frame sfonda `rows`
|
|
225
225
|
// (stessa invariante di TASKS_PANE_CHROME).
|
|
226
226
|
const DETAIL_CHROME = 10;
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
227
|
+
// T91 — la ricerca dentro il detail: marginTop + riga del campo.
|
|
228
|
+
//
|
|
229
|
+
// `MODAL_HEIGHT` dice che il detail costa 0 ai due pane (li sostituisce), e da
|
|
230
|
+
// lì è facile leggere «dentro l'overlay lo spazio è gratis». Le due contabilità
|
|
231
|
+
// sono distinte: quella dice quanto l'overlay toglie ai PANE, questa quanto
|
|
232
|
+
// toglie al PROPRIO contenuto. Una riga fissa aggiunta e non scalata è ciò che
|
|
233
|
+
// fa sfondare `rows` e manda Ink nel ramo `clearTerminal`, che su VTE riversa un
|
|
234
|
+
// frame nello scrollback a ogni tick del poll.
|
|
235
|
+
const DETAIL_SEARCH_CHROME = 2;
|
|
236
|
+
/** Righe di task file che entrano nel terminale; con la ricerca aperta il campo
|
|
237
|
+
* si paga qui, non altrove. */
|
|
238
|
+
export function detailCapacity(rows, searching = false) {
|
|
239
|
+
const extra = searching ? DETAIL_SEARCH_CHROME : 0;
|
|
240
|
+
return Math.max(0, (rows || 24) - SLACK - DETAIL_CHROME - extra);
|
|
230
241
|
}
|
|
231
242
|
// T52 — cornice del pannello di anteprima sotto la lista occorrenze:
|
|
232
243
|
// marginTop + 2 bordi + riga meta.
|
package/package.json
CHANGED