@lamemind/loom-deck 0.14.0 → 0.15.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/README.md +13 -1
- package/dist/cli.js +113 -24
- package/dist/search.js +20 -8
- package/dist/viewport.js +18 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -148,7 +148,19 @@ che non può mai produrre un risultato.
|
|
|
148
148
|
|
|
149
149
|
Con l'hash vuoto la lista è **raggruppata per conversazione**; `⏎` è contestuale
|
|
150
150
|
alla riga selezionata — su una riga-conversazione **riprende** la sessione
|
|
151
|
-
(come dal pane Sessions), su una riga-occorrenza apre il **reader**.
|
|
151
|
+
(come dal pane Sessions), su una riga-occorrenza apre il **reader**. La selezione
|
|
152
|
+
parte dalla prima *occorrenza*, non dalla riga di gruppo: quella è un segnaposto
|
|
153
|
+
di navigazione, raggiungibile con le frecce ma non una destinazione.
|
|
154
|
+
|
|
155
|
+
L'estratto attorno al match **si allarga col terminale**: a 190 colonne sono
|
|
156
|
+
~170 caratteri di contesto, non i 50 di un valore fisso — ed è il contesto la
|
|
157
|
+
ragione per cui si legge la riga invece di aprire il reader.
|
|
158
|
+
|
|
159
|
+
Sotto la lista, un **pannello di anteprima** mostra il contesto attorno
|
|
160
|
+
all'occorrenza selezionata e si aggiorna navigando con le frecce. Prende solo le
|
|
161
|
+
righe che la lista non usa: con pochi risultati riempie il terminale, con molti
|
|
162
|
+
sparisce e la lista se le riprende — quando c'è tanto da scorrere la priorità è
|
|
163
|
+
vedere più occorrenze, il contesto è il premio per una ricerca già stretta.
|
|
152
164
|
|
|
153
165
|
Il reader mostra il messaggio intero, aperto già **posizionato sull'occorrenza**
|
|
154
166
|
col match evidenziato: `↑↓` riga, `PgUp`/`PgDn` pagina, `g`/`G` estremi, `esc`
|
package/dist/cli.js
CHANGED
|
@@ -13,7 +13,7 @@ import { buildRows, firstRowKey, moveRowSelection, rowIndexOfKey, searchSessions
|
|
|
13
13
|
import { appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
|
|
14
14
|
import { assembleSessionList, firstSelectableId, moveSelection, rowIndexOf, selectedSession, } from './session-list.js';
|
|
15
15
|
import { launchLegend, loadIdentity, loadLaunch } from './config.js';
|
|
16
|
-
import { isCompact, layoutBudget, normalizeEmoji, readerCapacity, searchListCapacity, windowRange, wrapLines, wrapWithOffsets, } from './viewport.js';
|
|
16
|
+
import { isCompact, layoutBudget, normalizeEmoji, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, wrapLines, wrapWithOffsets, } from './viewport.js';
|
|
17
17
|
import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
18
18
|
import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
|
|
19
19
|
import { loadView, saveView, viewFilePath } from './view-store.js';
|
|
@@ -497,11 +497,19 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
497
497
|
// ≤9 ms su tutti i tipi — sotto il tempo fra due battute. Il memo evita di
|
|
498
498
|
// rifarla sui re-render che non toccano né query né opzioni (il poll delle
|
|
499
499
|
// sessioni ogni 1,5s è già filtrato dalla signature in `useSessions`).
|
|
500
|
-
const searchResult = useMemo(() => searchSessions(sessions, searchHash, searchQuery, searchOpts), [sessions, searchHash, searchQuery, searchOpts]);
|
|
500
|
+
const searchResult = useMemo(() => searchSessions(sessions, searchHash, searchQuery, searchOpts, searchExcerptWidth(columns)), [sessions, searchHash, searchQuery, searchOpts, columns]);
|
|
501
501
|
// Con l'hash valorizzato la conversazione è una sola e già nominata nel campo:
|
|
502
502
|
// la riga-sessione ripeterebbe un dato costante rubando una riga per gruppo.
|
|
503
503
|
const searchFlat = searchHash.trim().length > 0;
|
|
504
504
|
const searchRows = useMemo(() => buildRows(searchResult, searchFlat), [searchResult, searchFlat]);
|
|
505
|
+
// T52 — riga selezionata e, se è un'occorrenza, il suo corpo wrappato per
|
|
506
|
+
// l'anteprima sotto la lista. Memoizzato per (testo, larghezza): navigando
|
|
507
|
+
// con le frecce si ri-wrappa solo quando cambia davvero l'occorrenza.
|
|
508
|
+
const searchSelRow = useMemo(() => selectedRow(searchRows, searchSelKey), [searchRows, searchSelKey]);
|
|
509
|
+
const searchPreviewWidth = Math.max(20, (columns || 80) - 8);
|
|
510
|
+
const searchPreviewBody = useMemo(() => searchSelRow?.kind === 'hit'
|
|
511
|
+
? wrapWithOffsets(searchSelRow.hit.text, searchPreviewWidth)
|
|
512
|
+
: [], [searchSelRow, searchPreviewWidth]);
|
|
505
513
|
// T52 — corpo del messaggio aperto nel reader, wrappato UNA volta per (testo,
|
|
506
514
|
// larghezza). Senza memo ogni pressione di freccia rifarebbe l'a-capo di un
|
|
507
515
|
// messaggio che nella coda lunga arriva a 150k char.
|
|
@@ -673,22 +681,52 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
673
681
|
setSearchOpts((o) => ({ ...o, kinds: { ...o.kinds, [kind]: !o.kinds[kind] } }));
|
|
674
682
|
}
|
|
675
683
|
function editSearchField(fn) {
|
|
684
|
+
// La nota racconta l'esito di un'AZIONE su una lista che, con l'eager, si
|
|
685
|
+
// ricostruisce a ogni carattere: appena la query cambia è già scaduta.
|
|
686
|
+
// Lasciarla lì la fa leggere come se descrivesse lo stato corrente — nello
|
|
687
|
+
// specifico «nessuna occorrenza selezionata» sopra una lista con una riga
|
|
688
|
+
// visibilmente selezionata, cioè una contraddizione a schermo.
|
|
689
|
+
setNote('');
|
|
676
690
|
if (searchField === 'hash')
|
|
677
691
|
setSearchHash(fn);
|
|
678
692
|
else
|
|
679
693
|
setSearchQuery(fn);
|
|
680
694
|
}
|
|
681
695
|
// `useInput` consegna il CHUNK letto da stdin, non un tasto: un incollaggio —
|
|
682
|
-
// o una raffica di tasti
|
|
683
|
-
// byte di controllo compresi.
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
//
|
|
696
|
+
// o una raffica di tasti piu' veloce di una read — arriva come stringa unica,
|
|
697
|
+
// byte di controllo compresi. Due conseguenze, entrambe verificate su pty:
|
|
698
|
+
//
|
|
699
|
+
// 1. I byte di controllo finiscono DENTRO il campo se non li si filtra. Non
|
|
700
|
+
// si vedono, ma Ink li conta nella larghezza della riga e nessun match li
|
|
701
|
+
// soddisfa -> la ricerca smette di trovare senza dire perche'. Le newline
|
|
702
|
+
// diventano spazio invece di sparire: incollare due righe deve separare
|
|
703
|
+
// le parole, non fonderle.
|
|
704
|
+
//
|
|
705
|
+
// 2. Un TAB digitato subito dopo una lettera (~60 ms, cioe' battitura veloce
|
|
706
|
+
// normale) arriva incollato ad essa: `key.tab` resta falso e il ramo del
|
|
707
|
+
// cambio campo non scatta mai. Trattarlo come testo lo renderebbe uno
|
|
708
|
+
// spazio, per giunta nel campo sbagliato — e' cosi' che `70897aff` + Tab
|
|
709
|
+
// + `congelat` finiva tutto quanto nel campo hash.
|
|
710
|
+
//
|
|
711
|
+
// Si spezza quindi il chunk SUL TAB applicando il cambio campo in mezzo: il
|
|
712
|
+
// risultato e' identico alla battitura lenta. Stessa lezione del modale sort
|
|
713
|
+
// (T39), che cicla sui caratteri del chunk invece di leggerlo intero.
|
|
688
714
|
function typeIntoField(chunk) {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
715
|
+
setNote('');
|
|
716
|
+
const clean = (s) => s.replace(/[\r\n]/g, ' ').replace(/[\u0000-\u001f\u007f]/g, '');
|
|
717
|
+
const parts = chunk.split('\t');
|
|
718
|
+
let field = searchField;
|
|
719
|
+
const add = { hash: '', query: '' };
|
|
720
|
+
for (let k = 0; k < parts.length; k++) {
|
|
721
|
+
if (k > 0)
|
|
722
|
+
field = field === 'hash' ? 'query' : 'hash';
|
|
723
|
+
add[field] += clean(parts[k]);
|
|
724
|
+
}
|
|
725
|
+
if (add.hash)
|
|
726
|
+
setSearchHash((v) => v + add.hash);
|
|
727
|
+
if (add.query)
|
|
728
|
+
setSearchQuery((v) => v + add.query);
|
|
729
|
+
setSearchField(field);
|
|
692
730
|
}
|
|
693
731
|
const searchCap = searchListCapacity(rows, Boolean(note));
|
|
694
732
|
useInput((input, key) => {
|
|
@@ -1095,7 +1133,26 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1095
1133
|
}
|
|
1096
1134
|
const selIdx = rowIndexOfKey(searchRows, searchSelKey);
|
|
1097
1135
|
const win = windowRange(searchRows.length, selIdx, searchCap);
|
|
1098
|
-
|
|
1136
|
+
// Anteprima dell'occorrenza selezionata: prende le righe che la lista non
|
|
1137
|
+
// usa. Con molti risultati `spare` è 0 e il pannello non esiste — la lista
|
|
1138
|
+
// se le riprende tutte, che è la priorità giusta quando c'è molto da
|
|
1139
|
+
// scorrere. La finestra si CENTRA sul match (`windowRange`), così il
|
|
1140
|
+
// contesto arriva da entrambi i lati.
|
|
1141
|
+
const spare = searchPreviewCapacity(searchCap, win.end - win.start);
|
|
1142
|
+
let preview = null;
|
|
1143
|
+
if (spare >= 1 && searchSelRow?.kind === 'hit') {
|
|
1144
|
+
const h = searchSelRow.hit;
|
|
1145
|
+
const mline = Math.max(0, searchPreviewBody.findIndex((l) => l.end > h.matchStart));
|
|
1146
|
+
const pw = windowRange(searchPreviewBody.length, mline, spare);
|
|
1147
|
+
preview = {
|
|
1148
|
+
hit: h,
|
|
1149
|
+
lines: searchPreviewBody.slice(pw.start, pw.end),
|
|
1150
|
+
from: pw.start,
|
|
1151
|
+
total: searchPreviewBody.length,
|
|
1152
|
+
ts: sessions.find((s) => s.sessionId === h.sessionId)?.ts ?? 0,
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
return (_jsx(SearchScreen, { preview: preview, hash: searchHash, query: searchQuery, field: searchField, opts: searchOpts, result: searchResult, rows: searchRows.slice(win.start, win.end), selectedKey: searchSelKey, selectedKind: selectedRow(searchRows, searchSelKey)?.kind ?? null, above: win.start, below: searchRows.length - win.end, capacity: searchCap, bindings: bindings, pinned: pinned, projectCore: identity ? `${identity.owner} ${identity.name}` : null, columns: columns, note: note }));
|
|
1099
1156
|
}
|
|
1100
1157
|
// ── Budget d'altezza ────────────────────────────────────────────────────
|
|
1101
1158
|
// Il frame deve restare sotto `rows`, sempre: oltre quella soglia Ink smette
|
|
@@ -1171,6 +1228,31 @@ function EditModal({ id, draft, row }) {
|
|
|
1171
1228
|
// terminale misurano diversamente (vedi normalizeEmoji).
|
|
1172
1229
|
const KIND_TAG = { ai: 'ai', tool: 'tl', human: 'hu' };
|
|
1173
1230
|
const KIND_COLOR = { ai: 'cyan', tool: 'gray', human: 'green' };
|
|
1231
|
+
/**
|
|
1232
|
+
* Larghezza dell'estratto, DERIVATA dalle colonne del terminale.
|
|
1233
|
+
*
|
|
1234
|
+
* Una costante qui è spazio buttato a ogni riga: su un terminale a 190 colonne
|
|
1235
|
+
* un estratto fisso a 50 lascia il match con ~20 caratteri di contesto per lato
|
|
1236
|
+
* quando potrebbe averne 80 — e il contesto attorno al match è l'unica ragione
|
|
1237
|
+
* per cui si legge la riga invece di aprire il reader.
|
|
1238
|
+
*
|
|
1239
|
+
* Scomposizione delle colonne consumate dalla cornice e dal prefisso di riga:
|
|
1240
|
+
* 4 box esterno (2 bordi + 2 padding)
|
|
1241
|
+
* 4 box lista (2 bordi + 2 padding)
|
|
1242
|
+
* 2 caret
|
|
1243
|
+
* 4 indice del record
|
|
1244
|
+
* 3 spazio + tag del kind + spazio
|
|
1245
|
+
* Prudente per costruzione: sottostimare tronca un carattere in più,
|
|
1246
|
+
* sovrastimare manderebbe la riga a capo e sfonderebbe il budget d'altezza.
|
|
1247
|
+
*/
|
|
1248
|
+
function searchExcerptWidth(columns) {
|
|
1249
|
+
return Math.max(30, (columns || 80) - 17);
|
|
1250
|
+
}
|
|
1251
|
+
/** Titolo della conversazione sulla riga-gruppo: prende ciò che avanza dopo le
|
|
1252
|
+
* colonne a larghezza fissa (caret, pin, hash, task, conteggio, data). */
|
|
1253
|
+
function searchTitleWidth(columns) {
|
|
1254
|
+
return Math.max(24, (columns || 80) - 56);
|
|
1255
|
+
}
|
|
1174
1256
|
/**
|
|
1175
1257
|
* Cosa scrivere sulla riga-gruppo per distinguere una conversazione dall'altra.
|
|
1176
1258
|
*
|
|
@@ -1227,27 +1309,34 @@ function SearchListHeader({ result, query, above, below, }) {
|
|
|
1227
1309
|
}
|
|
1228
1310
|
return (_jsxs(Text, { bold: true, wrap: "truncate-end", children: [result.shown, " occorrenze in ", result.sessionCount, " conversazioni", result.hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", result.hidden, " oltre il cap"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }));
|
|
1229
1311
|
}
|
|
1230
|
-
|
|
1231
|
-
* Schermata di ricerca full-text (T52).
|
|
1232
|
-
*
|
|
1233
|
-
* Due campi + toggle + lista di occorrenze. Con l'hash vuoto la lista è
|
|
1234
|
-
* raggruppata per conversazione: la riga-gruppo NON ripete il nome del progetto
|
|
1235
|
-
* (D3: sono tutte dello stesso progetto, sarebbe una colonna costante) e usa lo
|
|
1236
|
-
* spazio per ciò che distingue davvero una conversazione — hash, task legata,
|
|
1237
|
-
* titolo, data.
|
|
1238
|
-
*/
|
|
1239
|
-
function SearchScreen({ hash, query, field, opts, result, rows, selectedKey, selectedKind, above, below, capacity, bindings, pinned, projectCore, note, }) {
|
|
1312
|
+
function SearchScreen({ preview, hash, query, field, opts, result, rows, selectedKey, selectedKind, above, below, capacity, bindings, pinned, projectCore, columns, note, }) {
|
|
1240
1313
|
const enter = selectedKind === 'session' ? 'resume' : selectedKind === 'hit' ? 'leggi' : '—';
|
|
1241
1314
|
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: ["ricerca \u00B7 ", _jsx(Text, { color: "yellow", children: "tab" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " naviga \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " ", enter, " \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " chiudi"] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "hash " }), _jsx(Text, { color: field === 'hash' ? 'yellow' : undefined, children: hash }), field === 'hash' ? _jsx(Text, { inverse: true, children: " " }) : null, !hash ? _jsx(Text, { dimColor: true, children: " (vuoto = tutte le conversazioni)" }) : null] }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "chiave " }), _jsx(Text, { color: field === 'query' ? 'yellow' : undefined, children: query }), field === 'query' ? _jsx(Text, { inverse: true, children: " " }) : null] }), _jsx(ToggleHint, { opts: opts })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsx(SearchListHeader, { result: result, query: query, above: above, below: below }), rows.slice(0, Math.max(0, capacity)).map((row) => {
|
|
1242
1315
|
const sel = row.key === selectedKey;
|
|
1243
1316
|
if (row.kind === 'session') {
|
|
1244
1317
|
const s = row.session;
|
|
1245
1318
|
const bound = bindings.get(s.sessionId);
|
|
1246
|
-
return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, pinned.has(s.sessionId) ? _jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" }) : _jsx(Text, { dimColor: true, children: "\u25CB" }), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, 8) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), bound ?? _jsx(Text, { dimColor: true, children: "spot" }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), truncate(conversationLabel(s, projectCore, bound),
|
|
1319
|
+
return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, pinned.has(s.sessionId) ? _jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" }) : _jsx(Text, { dimColor: true, children: "\u25CB" }), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, 8) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), bound ?? _jsx(Text, { dimColor: true, children: "spot" }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), truncate(conversationLabel(s, projectCore, bound), searchTitleWidth(columns)), _jsxs(Text, { dimColor: true, children: [' ', "(", row.hitCount, row.hidden > 0 ? `+${row.hidden}` : '', ") ", fmtDateTime(s.ts)] })] }, row.key));
|
|
1247
1320
|
}
|
|
1248
1321
|
const h = row.hit;
|
|
1249
1322
|
return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { dimColor: true, children: String(h.idx).padStart(4) }), ' ', _jsx(Text, { color: KIND_COLOR[h.kind], children: KIND_TAG[h.kind] }), " ", h.excerpt] }, row.key));
|
|
1250
|
-
})] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: normalizeEmoji(note) }) : null] }));
|
|
1323
|
+
})] }), preview ? _jsx(SearchPreviewPane, { p: preview }) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: normalizeEmoji(note) }) : null] }));
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Anteprima dell'occorrenza selezionata, sotto la lista.
|
|
1327
|
+
*
|
|
1328
|
+
* Riempie le righe che la lista non usa: con pochi risultati il terminale
|
|
1329
|
+
* resterebbe vuoto per tre quarti, e il contesto attorno al match è proprio
|
|
1330
|
+
* ciò che serve per decidere se è l'occorrenza giusta. Nel caso comune evita
|
|
1331
|
+
* del tutto di aprire il reader.
|
|
1332
|
+
*
|
|
1333
|
+
* Si aggiorna navigando con le frecce, e la finestra è centrata sul match:
|
|
1334
|
+
* stessa `windowRange` della lista, stessa evidenziazione del reader
|
|
1335
|
+
* (`ReaderLine`) — nessuna primitiva nuova.
|
|
1336
|
+
*/
|
|
1337
|
+
function SearchPreviewPane({ p }) {
|
|
1338
|
+
const last = Math.min(p.total, p.from + p.lines.length);
|
|
1339
|
+
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, from: p.hit.matchStart, to: p.hit.matchEnd }, p.from + i)))] }));
|
|
1251
1340
|
}
|
|
1252
1341
|
/**
|
|
1253
1342
|
* Reader fullscreen (T52 · D8).
|
package/dist/search.js
CHANGED
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
/** Sotto questa soglia non si cerca: 1-2 char matchano ovunque e la lista
|
|
20
20
|
* sarebbe rumore. Stato lecito, non un errore. */
|
|
21
21
|
export const MIN_QUERY = 3;
|
|
22
|
-
/** Larghezza
|
|
22
|
+
/** Larghezza di ripiego dell'estratto, usata solo quando il chiamante non
|
|
23
|
+
* conosce le colonne del terminale (test, uso non-TUI). In UI la larghezza
|
|
24
|
+
* arriva SEMPRE da `columns`: inchiodarla qui sprecherebbe tutto lo spazio di
|
|
25
|
+
* un terminale largo — su 190 colonne significa ~140 buttate per riga. */
|
|
23
26
|
export const EXCERPT_WIDTH = 50;
|
|
24
27
|
/** Cap per singola conversazione. Serve a tenere leggibile un gruppo, non a
|
|
25
28
|
* proteggere la memoria: una query corta può matchare centinaia di volte nello
|
|
@@ -121,7 +124,7 @@ export function excerptAround(text, start, end, width = EXCERPT_WIDTH) {
|
|
|
121
124
|
*
|
|
122
125
|
* `re` deve avere il flag `g` (lo garantisce buildMatcher).
|
|
123
126
|
*/
|
|
124
|
-
function scanBody(sessionId, body, re, need) {
|
|
127
|
+
function scanBody(sessionId, body, re, need, excerptWidth) {
|
|
125
128
|
const hits = [];
|
|
126
129
|
let count = 0;
|
|
127
130
|
re.lastIndex = 0;
|
|
@@ -135,7 +138,7 @@ function scanBody(sessionId, body, re, need) {
|
|
|
135
138
|
sessionId,
|
|
136
139
|
idx: body.idx,
|
|
137
140
|
kind: body.kind,
|
|
138
|
-
excerpt: excerptAround(body.text, start, end),
|
|
141
|
+
excerpt: excerptAround(body.text, start, end, excerptWidth),
|
|
139
142
|
matchStart: start,
|
|
140
143
|
matchEnd: end,
|
|
141
144
|
text: body.text,
|
|
@@ -155,7 +158,7 @@ function scanBody(sessionId, body, re, need) {
|
|
|
155
158
|
* uguaglianza: gli 8 char che la statusline mostra bastano a individuarla, e
|
|
156
159
|
* nessuno copia un uuid intero a mano.
|
|
157
160
|
*/
|
|
158
|
-
export function searchSessions(sessions, hashPrefix, query, opts) {
|
|
161
|
+
export function searchSessions(sessions, hashPrefix, query, opts, excerptWidth = EXCERPT_WIDTH) {
|
|
159
162
|
const prefix = hashPrefix.trim().toLowerCase();
|
|
160
163
|
const scoped = prefix
|
|
161
164
|
? sessions.filter((s) => s.sessionId.toLowerCase().startsWith(prefix))
|
|
@@ -178,7 +181,7 @@ export function searchSessions(sessions, hashPrefix, query, opts) {
|
|
|
178
181
|
// spazio nella lista. A zero la scansione prosegue lo stesso — serve il
|
|
179
182
|
// conteggio, che è ciò che alimenta «+N occorrenze».
|
|
180
183
|
const need = Math.max(0, Math.min(MAX_HITS_PER_SESSION - hits.length, MAX_TOTAL_HITS - shown - hits.length));
|
|
181
|
-
const r = scanBody(session.sessionId, body, re, need);
|
|
184
|
+
const r = scanBody(session.sessionId, body, re, need, excerptWidth);
|
|
182
185
|
found += r.count;
|
|
183
186
|
hits.push(...r.hits);
|
|
184
187
|
}
|
|
@@ -221,10 +224,19 @@ export function buildRows(result, flat) {
|
|
|
221
224
|
}
|
|
222
225
|
return rows;
|
|
223
226
|
}
|
|
224
|
-
/**
|
|
225
|
-
*
|
|
227
|
+
/**
|
|
228
|
+
* Riga su cui atterrare quando la selezione va ricostruita.
|
|
229
|
+
*
|
|
230
|
+
* Preferisce la prima **occorrenza**, non la prima riga in assoluto. In modalità
|
|
231
|
+
* raggruppata la lista si apre sempre con una riga-sessione, che è un
|
|
232
|
+
* segnaposto di navigazione e non una destinazione: atterrarci lascerebbe
|
|
233
|
+
* l'anteprima vuota all'apertura di ogni ricerca, e `⏎` prometterebbe un resume
|
|
234
|
+
* quando l'utente ha appena cercato delle occorrenze.
|
|
235
|
+
*
|
|
236
|
+
* Le righe-sessione restano raggiungibili con le frecce. null = lista vuota.
|
|
237
|
+
*/
|
|
226
238
|
export function firstRowKey(rows) {
|
|
227
|
-
return rows[0]?.key ?? null;
|
|
239
|
+
return (rows.find((r) => r.kind === 'hit') ?? rows[0])?.key ?? null;
|
|
228
240
|
}
|
|
229
241
|
/** Indice della riga con quella chiave; -1 se assente. */
|
|
230
242
|
export function rowIndexOfKey(rows, key) {
|
package/dist/viewport.js
CHANGED
|
@@ -202,6 +202,24 @@ const READER_CHROME = 8;
|
|
|
202
202
|
export function readerCapacity(rows) {
|
|
203
203
|
return Math.max(0, (rows || 24) - SLACK - READER_CHROME);
|
|
204
204
|
}
|
|
205
|
+
// T52 — cornice del pannello di anteprima sotto la lista occorrenze:
|
|
206
|
+
// marginTop + 2 bordi + riga meta.
|
|
207
|
+
const SEARCH_PREVIEW_CHROME = 4;
|
|
208
|
+
/**
|
|
209
|
+
* Righe di contesto concesse all'anteprima dell'occorrenza selezionata.
|
|
210
|
+
*
|
|
211
|
+
* Prende SOLO ciò che avanza: la lista si dimensiona sul contenuto e, quando i
|
|
212
|
+
* risultati sono pochi, il resto del terminale resterebbe vuoto. Con molti
|
|
213
|
+
* risultati lo spazio torna alla lista e il pannello sparisce da sé — la
|
|
214
|
+
* priorità è vedere quante più occorrenze possibile, il contesto è il premio
|
|
215
|
+
* per una ricerca già stretta.
|
|
216
|
+
*
|
|
217
|
+
* Restituisce 0 quando non c'è spazio nemmeno per una riga utile: un pannello
|
|
218
|
+
* di sola cornice ruberebbe 4 righe per non mostrare nulla.
|
|
219
|
+
*/
|
|
220
|
+
export function searchPreviewCapacity(capacity, listRows) {
|
|
221
|
+
return Math.max(0, capacity - listRows - SEARCH_PREVIEW_CHROME);
|
|
222
|
+
}
|
|
205
223
|
/**
|
|
206
224
|
* Il terminale non ospita nemmeno la cornice della schermata: va sostituita da
|
|
207
225
|
* una riga singola, come fa `Budget.compact` per il deck.
|
package/package.json
CHANGED