@lamemind/loom-deck 0.29.0 → 0.30.1
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 +141 -19
- package/dist/search.js +2 -2
- package/dist/session-list.js +28 -0
- package/dist/text-search.js +100 -0
- package/dist/viewport.js +14 -3
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -13,11 +13,12 @@ import { discoverProjectSessions } from './sessions.js';
|
|
|
13
13
|
import { discoverLiveSessions, liveSig } from './live-sessions.js';
|
|
14
14
|
import { buildRows, firstRowKey, moveRowSelection, rowIndexOfKey, searchSessions, selectedRow, DEFAULT_OPTIONS, MIN_QUERY, } from './search.js';
|
|
15
15
|
import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
|
|
16
|
-
import { assembleSessionList, firstSelectableId, moveSelection, neighborId, rowIndexOf, rowLabel, selectedSession, sessionTitle, stripProjectCore, } from './session-list.js';
|
|
16
|
+
import { assembleSessionList, firstSelectableId, moveSelection, neighborId, rowIndexOf, rowLabel, selectedSession, sessionTitle, stripProjectCore, unpinLandingId, } from './session-list.js';
|
|
17
17
|
import { cellWidth, launchLegend, loadArchivableDays, loadIdentity, loadLaunch, } from './config.js';
|
|
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
|
|
@@ -1725,8 +1824,16 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1725
1824
|
}
|
|
1726
1825
|
else {
|
|
1727
1826
|
const isPinned = pinned.has(selSessionId);
|
|
1827
|
+
// Dove atterra la selezione: sul pin resta sulla sessione (sale in cima
|
|
1828
|
+
// con lei, il caret la segue perché è l'oggetto dell'azione); sull'unpin
|
|
1829
|
+
// resta IN PLACE nel gruppo pinnate — successiva, precedente, o prima
|
|
1830
|
+
// contestuale se il gruppo si svuota. Calcolato PRIMA della riscrittura
|
|
1831
|
+
// del sidecar, quando la riga pinnata esiste ancora.
|
|
1832
|
+
const landing = isPinned ? unpinLandingId(sessionRows, selSessionId) : null;
|
|
1728
1833
|
appendPin(cwd, selSessionId, !isPinned);
|
|
1729
1834
|
reloadSessions();
|
|
1835
|
+
if (landing)
|
|
1836
|
+
setSelSessionId(landing);
|
|
1730
1837
|
setNote(`${isPinned ? 'unpin' : '📌 pin'} ${selSessionId.slice(0, 8)}`);
|
|
1731
1838
|
}
|
|
1732
1839
|
}
|
|
@@ -1875,7 +1982,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1875
1982
|
// posizione è lo scroll mosso a mano. Il clamp serve comunque — un resize
|
|
1876
1983
|
// può accorciare il testo sotto uno scroll già dato.
|
|
1877
1984
|
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 }));
|
|
1985
|
+
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
1986
|
}
|
|
1880
1987
|
// ── T52 · schermate sostitutive ─────────────────────────────────────────
|
|
1881
1988
|
// Ricerca e reader sono gli unici modali che NON stanno in flusso sopra i
|
|
@@ -2133,7 +2240,8 @@ function SearchScreen({ preview, hash, query, field, opts, result, rows, selecte
|
|
|
2133
2240
|
*/
|
|
2134
2241
|
function SearchPreviewPane({ p }) {
|
|
2135
2242
|
const last = Math.min(p.total, p.from + p.lines.length);
|
|
2136
|
-
|
|
2243
|
+
const occ = [{ start: p.hit.matchStart, end: p.hit.matchEnd }];
|
|
2244
|
+
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
2245
|
}
|
|
2138
2246
|
/**
|
|
2139
2247
|
* Reader fullscreen (T52 · D8).
|
|
@@ -2145,18 +2253,32 @@ function SearchPreviewPane({ p }) {
|
|
|
2145
2253
|
*/
|
|
2146
2254
|
function ReaderScreen({ hit, lines, top, total, capacity, bound, }) {
|
|
2147
2255
|
const last = Math.min(total, top + capacity);
|
|
2148
|
-
|
|
2256
|
+
const occ = [{ start: hit.matchStart, end: hit.matchEnd }];
|
|
2257
|
+
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
2258
|
}
|
|
2150
|
-
/** Una riga
|
|
2151
|
-
*
|
|
2152
|
-
*
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2259
|
+
/** Una riga con le porzioni di match evidenziate. Gli offset sono quelli del
|
|
2260
|
+
* testo sorgente, quindi un match a cavallo dell'a-capo si colora su entrambe
|
|
2261
|
+
* le righe senza casi speciali — entrambe intersecano il suo intervallo.
|
|
2262
|
+
*
|
|
2263
|
+
* Regge N occorrenze perché il detail (T91) ne mostra tutte quelle visibili; il
|
|
2264
|
+
* reader (T52) ne passa una sola, che è il caso degenere dello stesso taglio. */
|
|
2265
|
+
function ReaderLine({ line, occ, current, }) {
|
|
2266
|
+
const segs = sliceLine(line.text, line.start, occ, current);
|
|
2267
|
+
if (segs.length === 0)
|
|
2157
2268
|
return _jsx(Text, { wrap: "truncate-end", children: line.text || ' ' });
|
|
2158
|
-
|
|
2159
|
-
|
|
2269
|
+
return (_jsx(Text, { wrap: "truncate-end", children: segs.map((s, i) => s.hit ? (
|
|
2270
|
+
// La corrente si distingue dalle altre per COLORE di sfondo, non per
|
|
2271
|
+
// presenza: tutte restano visibili, o navigare fra occorrenze non
|
|
2272
|
+
// mostrerebbe più dove sono le altre.
|
|
2273
|
+
_jsx(Text, { backgroundColor: s.current ? 'cyan' : 'yellow', color: "black", children: s.text }, i)) : (_jsx(Text, { children: s.text }, i))) }));
|
|
2274
|
+
}
|
|
2275
|
+
/** Campo della ricerca nel detail: finestra ancorata al caret, cursore inverso
|
|
2276
|
+
* sulla cella reale. Gemello di `EditTextField` senza la label, che qui sta
|
|
2277
|
+
* fuori perché il campo vive in FLUSSO su una riga condivisa col contatore —
|
|
2278
|
+
* non su una riga propria. */
|
|
2279
|
+
function DetailFindField({ value, caret, cols }) {
|
|
2280
|
+
const win = caretWindow(value, caret, cols);
|
|
2281
|
+
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
2282
|
}
|
|
2161
2283
|
/**
|
|
2162
2284
|
* Detail della task (T66): il task file scrollabile più la barra azioni.
|
|
@@ -2171,7 +2293,7 @@ function ReaderLine({ line, from, to }) {
|
|
|
2171
2293
|
* all'arrivo del mouse (T21 · SGR enable + hit-test) senza migrazione. La
|
|
2172
2294
|
* navigazione da tastiera ci si sovrappone senza conflitti.
|
|
2173
2295
|
*/
|
|
2174
|
-
function DetailScreen({ id, title, missing, lines, top, total, capacity, action, columns, }) {
|
|
2296
|
+
function DetailScreen({ id, title, missing, lines, top, total, capacity, action, columns, find, occ, occCur, }) {
|
|
2175
2297
|
const last = Math.min(total, top + capacity);
|
|
2176
2298
|
// Il taglio lo fa il chiamante (invariante ③ di width.ts): la riga bottoni è
|
|
2177
2299
|
// ASCII, quindi `truncate-end` oggi darebbe il risultato giusto per caso — ma
|
|
@@ -2192,10 +2314,10 @@ function DetailScreen({ id, title, missing, lines, top, total, capacity, action,
|
|
|
2192
2314
|
if (dropped(shown) > 0)
|
|
2193
2315
|
shown = cutParts(parts, Math.max(0, width - 6));
|
|
2194
2316
|
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
|
|
2317
|
+
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
2318
|
// Riga vuota → uno spazio: un `<Text>` senza contenuto Ink non lo
|
|
2197
2319
|
// disegna, e il testo si compatterebbe perdendo la struttura del file.
|
|
2198
|
-
lines.map((l, i) => (_jsx(Text, { wrap: "truncate-end", children:
|
|
2320
|
+
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
2321
|
}
|
|
2200
2322
|
/**
|
|
2201
2323
|
* 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
|
package/dist/session-list.js
CHANGED
|
@@ -197,6 +197,34 @@ export function neighborId(rows, sessionId) {
|
|
|
197
197
|
return null;
|
|
198
198
|
return selectable[at + 1]?.sessionId ?? selectable[at - 1]?.sessionId ?? null;
|
|
199
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Id su cui atterrare quando la riga pinnata `sessionId` viene SPINNATA: la
|
|
202
|
+
* pinnata successiva, la precedente se era l'ultima, la prima contestuale se il
|
|
203
|
+
* gruppo pinnate si svuota. `null` se `sessionId` non è pinnata o non resta
|
|
204
|
+
* nessuna riga.
|
|
205
|
+
*
|
|
206
|
+
* Non è `neighborId` ristretto al gruppo: quello attraversa il separatore,
|
|
207
|
+
* quindi sull'ULTIMA pinnata atterrerebbe sulla prima contestuale — il caret
|
|
208
|
+
* scavalcherebbe l'intero gruppo invece di risalire di una riga. Qui il confine
|
|
209
|
+
* fra i due gruppi è la regola, non un ostacolo.
|
|
210
|
+
*
|
|
211
|
+
* L'id va calcolato PRIMA di riscrivere il sidecar: dopo, la riga pinnata non
|
|
212
|
+
* c'è più e il vicino nel gruppo non è calcolabile. Serve perché una spinnata
|
|
213
|
+
* non sparisce necessariamente dalla lista — se appartiene al parent
|
|
214
|
+
* selezionato ricompare fra le contestuali, e la selezione keyed sull'id la
|
|
215
|
+
* seguirebbe fin laggiù, trascinando il caret.
|
|
216
|
+
*/
|
|
217
|
+
export function unpinLandingId(rows, sessionId) {
|
|
218
|
+
const pinnedRows = rows.filter((r) => r.kind === 'pinned');
|
|
219
|
+
const at = pinnedRows.findIndex((r) => r.sessionId === sessionId);
|
|
220
|
+
if (at < 0)
|
|
221
|
+
return null;
|
|
222
|
+
const inGroup = pinnedRows[at + 1]?.sessionId ?? pinnedRows[at - 1]?.sessionId;
|
|
223
|
+
if (inGroup)
|
|
224
|
+
return inGroup;
|
|
225
|
+
const firstContext = rows.find((r) => r.kind === 'context');
|
|
226
|
+
return firstContext && isSelectable(firstContext) ? firstContext.sessionId : null;
|
|
227
|
+
}
|
|
200
228
|
/** Session della riga selezionata; null se stale o nessuna selezione. */
|
|
201
229
|
export function selectedSession(rows, sessionId) {
|
|
202
230
|
const r = rows.find((row) => isSelectable(row) && row.sessionId === sessionId);
|
|
@@ -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