pi-odd-worktree 0.0.0-stage → 1.0.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.
Files changed (60) hide show
  1. package/README.md +234 -2
  2. package/extensions/odd-worktree-tools.ts +7593 -0
  3. package/lib/checkout-facts.js +598 -0
  4. package/lib/clipboard.js +226 -0
  5. package/lib/gh-issues.js +1555 -0
  6. package/lib/issue-preview.js +273 -0
  7. package/lib/issues-view.js +2274 -0
  8. package/lib/odd-docs.js +505 -0
  9. package/lib/odd-markdown.js +413 -0
  10. package/lib/odd-view.js +990 -0
  11. package/lib/open-external.js +106 -0
  12. package/lib/preview-scroll-keys.js +192 -0
  13. package/lib/session-auto-name.js +292 -0
  14. package/lib/session-worktree-register.js +62 -0
  15. package/lib/tui-paint.js +273 -0
  16. package/lib/view-layout.js +368 -0
  17. package/lib/warp-create-panel.js +722 -0
  18. package/lib/warp-create-picker.js +906 -0
  19. package/lib/warp-delete-confirm.js +465 -0
  20. package/lib/warp-selector-view.js +1265 -0
  21. package/lib/worktree-card.js +1117 -0
  22. package/lib/worktree-changes.js +1395 -0
  23. package/lib/worktree-close-herdr.js +73 -0
  24. package/lib/worktree-git.js +1048 -0
  25. package/lib/worktree-hooks.js +738 -0
  26. package/lib/worktree-model-result.js +291 -0
  27. package/lib/worktree-registry.js +886 -0
  28. package/lib/worktree-session-guard.js +595 -0
  29. package/lib/worktree-symlinks.js +336 -0
  30. package/lib/worktree-warp-sessions.js +566 -0
  31. package/package.json +36 -4
  32. package/tools/commands.test.js +176 -0
  33. package/tools/fixtures/gh-api-user.json +1 -0
  34. package/tools/fixtures/gh-issue-list.json +1 -0
  35. package/tools/fixtures/gh-issue-view.json +1 -0
  36. package/tools/fixtures/gh-label-list.json +1 -0
  37. package/tools/fixtures/gh-repo-view.json +1 -0
  38. package/tools/fixtures/load-extension.js +66 -0
  39. package/tools/gh-issues-feedback.test.js +359 -0
  40. package/tools/gh-issues.test.js +1502 -0
  41. package/tools/issue-preview.test.js +216 -0
  42. package/tools/issues-command.test.js +1217 -0
  43. package/tools/issues-view.test.js +4401 -0
  44. package/tools/odd-docs.test.js +803 -0
  45. package/tools/odd-markdown.test.js +236 -0
  46. package/tools/odd-view.test.js +2328 -0
  47. package/tools/package-surface.test.js +138 -0
  48. package/tools/t5-session-identity.test.js +306 -0
  49. package/tools/warp-create-panel.test.js +185 -0
  50. package/tools/warp-create-picker.test.js +1357 -0
  51. package/tools/warp-delete-confirm.test.js +616 -0
  52. package/tools/warp-selector-view.test.js +1463 -0
  53. package/tools/worktree-changes.test.js +951 -0
  54. package/tools/worktree-close-herdr-async.test.js +204 -0
  55. package/tools/worktree-odd-migration.test.js +224 -0
  56. package/tools/worktree-registry-cache.test.js +163 -0
  57. package/tools/worktree-registry-odd.test.js +231 -0
  58. package/tools/worktree-registry.test.js +244 -0
  59. package/tools/worktree-session-guard.test.js +507 -0
  60. package/tools/worktree-tools.test.js +434 -0
@@ -0,0 +1,990 @@
1
+ 'use strict';
2
+ // Odd viewer component (odd-viewer S3,S4,S6).
3
+ //
4
+ // S3: the left pane takes the smaller 30% list share and its documents are ONE
5
+ // continuously scrolling window — `←`/`→` move it a screenful and the focused
6
+ // document always stays inside it; there is no pager and no page marker.
7
+ //
8
+ // S12: the list opens in the loader's own sequence — most recently modified
9
+ // document first — and ONE shortcut (`s`) alternates it with a case-insensitive
10
+ // alphabetical order. The focused document survives the toggle, the visible
11
+ // window re-anchors on its new position, and the footer names both the key and
12
+ // the ACTIVE order.
13
+ //
14
+ // The host component contract Pi asks for (the same shape the customize
15
+ // panel uses in extensions/gentle-worktree-tools.ts): the factory returns
16
+ // { render(width), handleInput(data), invalidate(), handleMouse(event) }.
17
+ // Selection and scroll are LOCAL state: no key writes, notifies or reloads,
18
+ // and `esc` finishes through done(null). Nothing here reads or writes
19
+ // during render: the document is read once per view and scrolling only
20
+ // moves the window.
21
+ //
22
+ // S10b: the view is read-only with respect to the checkout↔document
23
+ // association. The `assigned`, `saveAssignment` and `assignmentEnabled`
24
+ // options the existing callers still pass are accepted and ignored — no
25
+ // mark, no toggle, no save — and the association lives in the worktree
26
+ // detail instead.
27
+ //
28
+ // The TUI helpers arrive injected (visibleWidth/truncateToWidth/matchesKey/
29
+ // isKeyRelease) because @earendil-works/pi-tui is a lazily imported peer of
30
+ // this package: the command loads it and passes it down, and the raw-byte
31
+ // fallbacks below keep the keys working when a host provides none. Layout
32
+ // follows the reference Changes view: a framed two-pane card, list on the
33
+ // left, document on the right, the keys on the ONE row above the bottom rule.
34
+
35
+ const { renderOddMarkdown, stripAnsi } = require('./odd-markdown.js');
36
+ const layout = require('./view-layout.js');
37
+ const scrollKeys = require('./preview-scroll-keys.js');
38
+ // S11: the ONE palette both readers share. Every footer key, footer
39
+ // description, frame title, count, label and live search value paints through
40
+ // it, so a shortcut key is always distinguishable from the words beside it and
41
+ // this view keeps no second copy of the hostile-theme guards.
42
+ const palette = require('./tui-paint.js');
43
+ const paint = palette.paint;
44
+ const emphasize = palette.emphasize;
45
+ const highlight = palette.highlight;
46
+
47
+ const MIN_WIDTH = 24;
48
+ // The initial list width, until the host paints the first frame and the shared
49
+ // 30/70 reader rule reports the real one: a conventional 80-column terminal.
50
+ const DEFAULT_LIST_WIDTH = layout.paneWidths(layout.innerWidth(80), { listShare: layout.READER_LIST_SHARE }).left;
51
+ const EMPTY_LIST = 'no odd documents';
52
+ const EMPTY_HINT = 'create one under odd/tasks/';
53
+ // S2: ONE legend, ONE terminal row — every key rides the same row instead of
54
+ // a second footer row. `text` is the full token, `short` the compact form used
55
+ // when the full one does not fit, and `priority` decides what a
56
+ // still-too-wide legend drops first (lowest first), so `esc close` outlives
57
+ // every other hint.
58
+ //
59
+ // S4 supersedes the earlier split: ALL hints belong to the footer, including
60
+ // the search key, so the search row above carries the query alone.
61
+ //
62
+ // S1: the preview scroll hints are NOT listed here. They come from the shared
63
+ // binding helper and are spliced after the selection hint in `contentKeys()`,
64
+ // so this legend and that handler can never disagree about the pair.
65
+ //
66
+ // S14: every scroll hint names the pane it moves, so the list and the document
67
+ // can never be read as the same binding («alguna key generidad como el page o
68
+ // half estan demas»).
69
+ //
70
+ // S8 («salen flecha izq/der ... esta bien el atajo pero demas marcarlo»): the
71
+ // list scroll pair keeps its BINDING and leaves this legend. `←`/`→` already
72
+ // read as movement, the document token beside them names its own target, and
73
+ // `[`/`]` were always undocumented alternates — so the extra `←/→ list` token
74
+ // only spent cells on a key the reader could simply try. Only the HINT is gone:
75
+ // the handler below still resolves `←`/`→` and `[`/`]`.
76
+ const CONTENT_KEYS = [
77
+ { key: '↑↓', text: '↑↓ select', short: '↑↓', priority: 60 },
78
+ // The legend spells the lowercase search key; `T` keeps opening the box.
79
+ { key: 't', text: 't search', short: 't', priority: 45 },
80
+ { key: 'o', text: 'o open', short: 'o open', priority: 40 },
81
+ { key: 'esc', text: 'esc close', short: 'esc close', priority: 70 },
82
+ ];
83
+
84
+ // S12: the two list orders and the ONE key that alternates them. `recent` is
85
+ // the loader's own sequence (lib/odd-docs.js already sorts by modification
86
+ // time, newest first), so this view never re-sorts it; `name` is a sorted COPY
87
+ // of the same documents. `s` is free — no other binding in this view uses it —
88
+ // and a single token keeps the one-row legend cheap.
89
+ const ORDER = Object.freeze({ RECENT: 'recent', NAME: 'name' });
90
+ const ORDER_KEY = 's';
91
+ // S9: the reload key, the outcome state its footer token reports ('idle' keeps
92
+ // the plain `r reload` hint) and where that token sits when the compact legend
93
+ // has to give a hint up.
94
+ const RELOAD_KEY = 'r';
95
+ // S9: the outcome needs a few cells more than the idle `r`, and the reported
96
+ // terminal is one where the idle token fits but the row is otherwise full. Placing
97
+ // the reload token just above the external-open hint (40) makes the legend give
98
+ // up `o open` for the answer instead of pruning the answer itself; it stays below
99
+ // `t search` (45) and `s sort` (50), so a live action hint is still preferred over
100
+ // a spent reload message on a still-narrower row.
101
+ const RELOAD_PRIORITY = 41;
102
+
103
+ // While the filter box holds the keyboard the footer names THAT interaction
104
+ // once — enter and esc each keep the filter, Delete clears the whole query and
105
+ // Backspace edits one character (S4). Each is a single token so the extra keys
106
+ // never inflate the ONE legend row (S2).
107
+ const SEARCH_KEYS = [
108
+ { key: 'type', text: 'type to filter', short: 'type', priority: 40 },
109
+ { key: 'del', text: 'del clear', short: 'del', priority: 50 },
110
+ { key: 'bksp', text: 'bksp edit', short: 'bksp', priority: 20 },
111
+ { key: 'enter/esc', text: 'enter/esc keep', short: 'enter/esc', priority: 60 },
112
+ ];
113
+
114
+ // S1: `J`/`K` are the preview paging keys now (the shared binding helper
115
+ // resolves them before any list key), so a loose case fallback must never let
116
+ // them answer for the `j`/`k` selection bindings — and never the reverse.
117
+ const SHIFTED_TWINS = new Set(['j', 'k']);
118
+
119
+ // Raw terminal bytes per key id, mirroring the installed pi-tui keys.js
120
+ // legacy sequences — the same convention lib/warp-create-picker.js uses.
121
+ // Bounded to the keys this view binds, so the preview stays drivable when
122
+ // the host helper module is absent (a scroll key that reads as frozen is
123
+ // worse than a redundant fallback). The preview scroll keys are not here:
124
+ // lib/preview-scroll-keys.js owns them, so the handler and the legend can
125
+ // never disagree about which pair is live.
126
+ const RAW_KEY_BYTES = {
127
+ escape: ['\x1b'],
128
+ 'ctrl+c': ['\x03'],
129
+ backspace: ['\x7f', '\b'],
130
+ up: ['\x1b[A'],
131
+ down: ['\x1b[B'],
132
+ left: ['\x1b[D', '\x1bOD'],
133
+ right: ['\x1b[C', '\x1bOC'],
134
+ delete: ['\x1b[3~', '\x1b[57427u'],
135
+ home: ['\x1b[H', '\x1bOH', '\x1b[1~', '\x1b[57423u'],
136
+ end: ['\x1b[F', '\x1bOF', '\x1b[4~', '\x1b[57424u'],
137
+ };
138
+
139
+ // Mouse wheel as SGR / legacy X10 bytes: some hosts deliver the wheel through
140
+ // handleMouse, older ones through the input stream. Both are parsed here so a
141
+ // wheel notch scrolls the preview either way. Button bit 64 flags the wheel;
142
+ // bits 0-1 are the direction (0 = up, 1 = down).
143
+ const WHEEL_SGR = /^\x1b\[<(\d+);\d+;\d+[Mm]$/;
144
+ const WHEEL_LEGACY = /^\x1b\[M([\s\S]{3})$/;
145
+
146
+ function wheelDeltaFromInput(data) {
147
+ if (typeof data !== 'string' || data.length < 6 || data[0] !== '\x1b') return undefined;
148
+ let button;
149
+ const sgr = WHEEL_SGR.exec(data);
150
+ if (sgr) {
151
+ button = Number.parseInt(sgr[1], 10);
152
+ } else {
153
+ const legacy = WHEEL_LEGACY.exec(data);
154
+ if (!legacy) return undefined;
155
+ button = legacy[1].charCodeAt(0) - 32;
156
+ }
157
+ if (!Number.isFinite(button) || (button & 64) === 0) return undefined;
158
+ const direction = button & 3;
159
+ if (direction !== 0 && direction !== 1) return undefined;
160
+ return direction === 0 ? -1 : 1;
161
+ }
162
+
163
+ function measure(helpers, text) {
164
+ try {
165
+ if (helpers && typeof helpers.visibleWidth === 'function') {
166
+ const size = Number(helpers.visibleWidth(text));
167
+ if (Number.isFinite(size)) return size;
168
+ }
169
+ } catch {
170
+ // Fall through to the ANSI-stripped codepoint count.
171
+ }
172
+ return [...stripAnsi(text)].length;
173
+ }
174
+
175
+ // S23: every paint re-wraps every listed document name — once to measure the
176
+ // rows it costs for paging, once to paint the rows actually shown — so a long
177
+ // list paid its whole wrap per keystroke. This memo is owned by ONE view
178
+ // instance and keyed by the EXACT name text, the list width and both row
179
+ // prefixes (the focus marker changes the rows), so only an unchanged name can
180
+ // hit it: a renamed or reloaded list, a resized pane or a moved cursor always
181
+ // wraps again and a stale row is unpaintable.
182
+ const LABEL_MEMO_ENTRIES = 2000;
183
+
184
+ function createLabelRows(helpers, limit = LABEL_MEMO_ENTRIES) {
185
+ const max = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : LABEL_MEMO_ENTRIES;
186
+ const entries = new Map();
187
+ // The returned rows are read-only for the callers (cost = length, paint =
188
+ // copy each row), so a hit may hand back the same array.
189
+ return function labelRows(text, width, first, rest) {
190
+ const key = `${width}\u0000${first}\u0000${rest}\u0000${text}`;
191
+ const cached = entries.get(key);
192
+ if (cached) {
193
+ // Re-insert, so the Map's own order is the eviction order.
194
+ entries.delete(key);
195
+ entries.set(key, cached);
196
+ return cached;
197
+ }
198
+ const rows = layout.wrapLabel(helpers, text, width, { first, rest });
199
+ entries.set(key, rows);
200
+ while (entries.size > max) {
201
+ const oldest = entries.keys().next();
202
+ if (oldest.done) break;
203
+ entries.delete(oldest.value);
204
+ }
205
+ return rows;
206
+ };
207
+ }
208
+
209
+ function fit(helpers, text, width) {
210
+ const clean = String(text).split('\n')[0] ?? '';
211
+ if (measure(helpers, clean) <= width) return clean;
212
+ try {
213
+ if (helpers && typeof helpers.truncateToWidth === 'function') {
214
+ const cut = helpers.truncateToWidth(clean, width, '…');
215
+ if (typeof cut === 'string') return cut;
216
+ }
217
+ } catch {
218
+ // Fall through to the plain slice.
219
+ }
220
+ const chars = [...clean];
221
+ return chars.length > width ? chars.slice(0, Math.max(0, width - 1)).join('') + '…' : clean;
222
+ }
223
+
224
+ function pad(helpers, text, width) {
225
+ const clipped = fit(helpers, text, width);
226
+ return clipped + ' '.repeat(Math.max(0, width - measure(helpers, clipped)));
227
+ }
228
+
229
+ // A row that carries its main text on the left and a value on the right edge
230
+ // (S13: the scroll position shares the search row). The value is dropped
231
+ // rather than allowed to crowd the left text out when the row is too narrow —
232
+ // S14 outranks it, so the Search widget and its page marker always survive.
233
+ function withRight(helpers, left, right, width) {
234
+ if (right === '') return pad(helpers, left, width);
235
+ const room = width - measure(helpers, right) - 2;
236
+ if (room < 8) return pad(helpers, left, width);
237
+ if (measure(helpers, left) > room) return pad(helpers, left, width);
238
+ const head = fit(helpers, left, room);
239
+ const gap = Math.max(1, width - measure(helpers, head) - measure(helpers, right));
240
+ return `${head}${' '.repeat(gap)}${right}`;
241
+ }
242
+
243
+ // S2: a key legend stays on ONE row. The full text is used while it fits;
244
+ // otherwise the legend compacts to its short tokens and then drops its
245
+ // lowest-priority keys (display order never changes, and no token carries a
246
+ // newline), so it never wraps onto a second row. The caller still pads, which
247
+ // keeps the row cell-exact if a single token is wider than the pane. The
248
+ // compaction and the role painting live in lib/tui-paint.js (S11), so the
249
+ // painted row and the measured one can never disagree.
250
+
251
+ function keyMatches(helpers, data, names) {
252
+ for (const name of names) {
253
+ try {
254
+ if (helpers && typeof helpers.matchesKey === 'function' && helpers.matchesKey(data, name)) return true;
255
+ } catch {
256
+ // A throwing matcher never matches; the raw fallback below decides.
257
+ }
258
+ }
259
+ if (typeof data === 'string' && data.length > 0) {
260
+ for (const name of names) {
261
+ const raw = RAW_KEY_BYTES[String(name).toLowerCase()];
262
+ if (raw && raw.includes(data)) return true;
263
+ }
264
+ }
265
+ // A host that classifies the raw bytes can still resolve the same name
266
+ // through its public parser, which is why the parser is consulted before the
267
+ // plain string comparison: a host may name one byte `pageDown` while another
268
+ // spells it `pagedown`.
269
+ let parsed = null;
270
+ try {
271
+ if (helpers && typeof helpers.parseKey === 'function') parsed = helpers.parseKey(data);
272
+ } catch {
273
+ // A throwing parser falls through to the plain comparison.
274
+ }
275
+ if (typeof parsed === 'string' && parsed.length > 0
276
+ && names.some((name) => sameKeyName(parsed, name))) return true;
277
+ const text = typeof data === 'string' ? data : (data && (data.name || data.key)) || '';
278
+ return names.some((name) => sameKeyName(text, name));
279
+ }
280
+
281
+ // Key ids are loose about case because hosts spell them differently
282
+ // (`pageDown` vs `pagedown`); only a shifted letter whose unshifted twin is a
283
+ // live binding (`J` next to `j`) stays case-sensitive.
284
+ function sameKeyName(left, right) {
285
+ const a = String(left);
286
+ const b = String(right);
287
+ if (a === b) return true;
288
+ const shiftedLetter = (text) => text.length === 1 && text >= 'A' && text <= 'Z';
289
+ if (shiftedLetter(a) && !shiftedLetter(b) && SHIFTED_TWINS.has(b.toLowerCase())) return false;
290
+ return a.toLowerCase() === b.toLowerCase();
291
+ }
292
+
293
+ function isKeyRelease(helpers, data) {
294
+ try {
295
+ if (helpers && typeof helpers.isKeyRelease === 'function') return helpers.isKeyRelease(data) === true;
296
+ } catch {
297
+ // A throwing classifier never releases; normal handling follows.
298
+ }
299
+ return false;
300
+ }
301
+
302
+ // S12: case-insensitive alphabetical order by display name, with the raw name
303
+ // as the deterministic tie-break for names that differ only in case. Plain
304
+ // code-unit comparison, never `localeCompare`: the painted order must not
305
+ // change with the host locale.
306
+ function compareByName(left, right) {
307
+ const a = String((left && left.name) || '');
308
+ const b = String((right && right.name) || '');
309
+ const folded = a.toLowerCase();
310
+ const other = b.toLowerCase();
311
+ if (folded !== other) return folded < other ? -1 : 1;
312
+ if (a === b) return 0;
313
+ return a < b ? -1 : 1;
314
+ }
315
+
316
+ function normalizeLoad(result) {
317
+ if (!result || typeof result !== 'object') return { ok: false, error: 'document unreadable' };
318
+ if (result.ok === true && typeof result.text === 'string') return { ok: true, text: result.text };
319
+ if (result.ok === false) return { ok: false, error: String(result.error || 'document unreadable') };
320
+ return { ok: false, error: 'document unreadable' };
321
+ }
322
+
323
+ function createOddView(options = {}) {
324
+ const {
325
+ helpers = null,
326
+ theme = null,
327
+ rowsAvailable = null,
328
+ requestRender = null,
329
+ done = null,
330
+ loadDoc = null,
331
+ reloadDocs = null,
332
+ openExternal = null,
333
+ } = options;
334
+ // S10b: `options.assigned`, `options.saveAssignment` and
335
+ // `options.assignmentEnabled` are deliberately not read: the association
336
+ // belongs to the worktree detail, and this view stays read-only.
337
+ let docs = Array.isArray(options.docs) ? options.docs.slice() : [];
338
+ const entries = new Map();
339
+ const state = { index: 0, scroll: 0, query: '', searching: false, listStart: 0, order: ORDER.RECENT };
340
+ let openError = null;
341
+ // S9: 'idle' | 'reloaded' | 'failed' — the outcome of the last `r` press.
342
+ let reloadState = 'idle';
343
+ let bodyRows = 1;
344
+ // S23: the per-instance document-name memo (see createLabelRows).
345
+ const labelRows = createLabelRows(helpers);
346
+ // The list pane width of the last frame, so a key press pages against the
347
+ // layout the reader is actually looking at.
348
+ let listWidthCache = DEFAULT_LIST_WIDTH;
349
+
350
+ // S24/S25: the title is the viewer and the count, nothing more. The
351
+ // checkout/branch identity is not repeated here (it is the session's own
352
+ // checkout) and neither is the selected document — that rides the status
353
+ // row next to the preview. `checkoutName` stays accepted for the existing
354
+ // callers but is deliberately not rendered. S11/S13: the two parts are
355
+ // separate tokens so the header can paint the viewer with the theme's
356
+ // secondary title role and the count muted; `titleText` joins them for the
357
+ // plain fallback line.
358
+ const TITLE_NAME = '✎ Odd';
359
+
360
+ function titleParts() {
361
+ return { name: TITLE_NAME, count: `${docs.length} documents` };
362
+ }
363
+
364
+ function titleText() {
365
+ const parts = titleParts();
366
+ return `${parts.name} · ${parts.count}`;
367
+ }
368
+
369
+ // The visible list: the filter narrows by document name
370
+ // (case-insensitive substring), exactly like the issues viewer keeps its
371
+ // query after leaving the box — selection indexes into this list. S12: the
372
+ // ACTIVE order decides the sequence of the filtered rows; the recency order
373
+ // is whatever the loader returned, so this view only ever sorts a copy.
374
+ function shown() {
375
+ const q = state.query.trim().toLowerCase();
376
+ const matched = q === ''
377
+ ? docs
378
+ : docs.filter((docEntry) => String((docEntry && docEntry.name) || '').toLowerCase().includes(q));
379
+ if (state.order !== ORDER.NAME) return matched;
380
+ return matched.slice().sort(compareByName);
381
+ }
382
+
383
+ function availableRows() {
384
+ try {
385
+ if (typeof rowsAvailable === 'function') {
386
+ const value = Math.floor(rowsAvailable());
387
+ if (Number.isFinite(value) && value > 0) return value;
388
+ }
389
+ } catch {
390
+ // A hostile rows source never breaks the view.
391
+ }
392
+ return 24;
393
+ }
394
+
395
+ function repaint() {
396
+ try {
397
+ if (typeof requestRender === 'function') requestRender();
398
+ } catch {
399
+ // A repaint failure never breaks navigation.
400
+ }
401
+ }
402
+
403
+ function current() {
404
+ const list = shown();
405
+ return list[state.index] || null;
406
+ }
407
+
408
+ function renderLines(entry, text, width) {
409
+ entry.lines = renderOddMarkdown(text, {
410
+ width,
411
+ paint: (role, value) => paint(theme, role, value),
412
+ emphasis: (value) => emphasize(theme, value),
413
+ measure: (value) => measure(helpers, value),
414
+ });
415
+ entry.text = text;
416
+ entry.width = width;
417
+ }
418
+
419
+ // One read per document per view: the entry keeps its text and rendered
420
+ // lines, so re-selecting a document never re-reads or blocks a frame.
421
+ function ensureEntry(docEntry) {
422
+ if (!docEntry) return null;
423
+ const key = docEntry.path || docEntry.name;
424
+ let entry = entries.get(key);
425
+ if (entry) return entry;
426
+ entry = { status: 'loading', lines: [], text: '', width: 0 };
427
+ entries.set(key, entry);
428
+ if (typeof loadDoc !== 'function') {
429
+ entry.status = 'error';
430
+ entry.error = 'document unreadable';
431
+ return entry;
432
+ }
433
+ let pending = null;
434
+ try {
435
+ pending = loadDoc(docEntry, key);
436
+ } catch (err) {
437
+ entry.status = 'error';
438
+ entry.error = (err && err.message) || 'document unreadable';
439
+ return entry;
440
+ }
441
+ const settle = (result) => {
442
+ const normalized = normalizeLoad(result);
443
+ if (normalized.ok) {
444
+ entry.status = 'ready';
445
+ renderLines(entry, normalized.text, entry.width || MIN_WIDTH);
446
+ } else {
447
+ entry.status = 'error';
448
+ entry.error = normalized.error;
449
+ }
450
+ repaint();
451
+ };
452
+ // A synchronous reader (this package reads a local file) settles before
453
+ // the next frame, so selecting a document never shows a placeholder for
454
+ // a read that already finished.
455
+ if (pending && typeof pending.then === 'function') {
456
+ pending.then(settle).catch((err) => {
457
+ entry.status = 'error';
458
+ entry.error = (err && err.message) || 'document unreadable';
459
+ repaint();
460
+ });
461
+ } else {
462
+ settle(pending);
463
+ }
464
+ return entry;
465
+ }
466
+
467
+ function clampScroll() {
468
+ const entry = current() ? ensureEntry(current()) : null;
469
+ const total = entry && entry.status === 'ready' ? entry.lines.length : 0;
470
+ const max = Math.max(0, total - Math.max(1, bodyRows));
471
+ if (state.scroll > max) state.scroll = max;
472
+ if (state.scroll < 0) state.scroll = 0;
473
+ }
474
+
475
+ // One place moves the document window, so every input path (keyboard,
476
+ // wheel event, wheel bytes) clamps and repaints identically and none of
477
+ // them writes or reads outside the view.
478
+ function scrollBy(delta) {
479
+ if (!Number.isFinite(delta) || delta === 0) return;
480
+ state.scroll += delta;
481
+ clampScroll();
482
+ repaint();
483
+ }
484
+
485
+ // The host delivers a wheel notch as a TuiMouseEvent (type `wheel`, a
486
+ // signed `wheelDelta`). The preview is the reading surface, so the wheel
487
+ // moves it from anywhere in the frame and never touches the list
488
+ // selection. Only the wheel is consumed; other events stay unhandled so
489
+ // the host keeps its own mouse behavior.
490
+ function handleMouse(event) {
491
+ if (!event || event.type !== 'wheel') return undefined;
492
+ const delta = Number(event.wheelDelta);
493
+ if (!Number.isFinite(delta) || delta === 0) return undefined;
494
+ scrollBy(delta);
495
+ return { handled: true, render: true };
496
+ }
497
+
498
+ // S3: the list is ONE continuous window whose capacity follows the rows each
499
+ // wrapped name actually needs, never a fixed document count. (The older "para
500
+ // odd igual, si son muchas igual se pagina" page marker went with the pager.)
501
+ function listCosts() {
502
+ return shown().map((docEntry) => labelRows(String((docEntry && docEntry.name) || ''), listWidthCache, '▸ ', ' ').length);
503
+ }
504
+
505
+ // The window the list paints: bounded by the body budget and always holding
506
+ // the focused document.
507
+ function listWindow() {
508
+ return layout.scrollWindow(listCosts(), {
509
+ budget: Math.max(1, bodyRows),
510
+ index: state.index,
511
+ start: state.listStart,
512
+ });
513
+ }
514
+
515
+ // S12: the order token names the ACTIVE order, so the ONE footer row says
516
+ // which sequence the list is in as well as which key flips it. It is built
517
+ // per frame (the order changes) and keeps the key/description split the rest
518
+ // of the palette uses: `s` is the key run, ` sort: …` its description.
519
+ function orderKey() {
520
+ return {
521
+ key: ORDER_KEY,
522
+ text: `${ORDER_KEY} sort: ${state.order === ORDER.NAME ? 'name' : 'recent'}`,
523
+ short: ORDER_KEY,
524
+ priority: 50,
525
+ };
526
+ }
527
+
528
+ // S1: the reader legend is the static keys plus the scroll hints the shared
529
+ // binding helper says this terminal can actually deliver.
530
+ //
531
+ // S14: the document pane's hints — the page pair, the half-page pair and the
532
+ // printable fallback — all scroll the SAME target, so they collapse into the
533
+ // ONE token that says so ("ctrl+j/k scroll"). Only the HINT collapses: the
534
+ // handler keeps resolving every one of those keys, so no binding is lost with
535
+ // its alias.
536
+ function contentKeys() {
537
+ const documentScroll = palette.scrollHint(scrollKeys.previewScrollKeys(), 'scroll');
538
+ // S12 and S9 splice their live-state tokens in beside the actions: the
539
+ // order toggle next to the search hint, the reload outcome where `r` sits.
540
+ const [selectKey, searchKey, ...actionKeys] = CONTENT_KEYS;
541
+ return [
542
+ selectKey,
543
+ documentScroll,
544
+ orderKey(),
545
+ searchKey,
546
+ reloadKey(),
547
+ ...actionKeys,
548
+ ].filter((entry) => entry !== null && entry !== undefined);
549
+ }
550
+
551
+ // S9 («el r en odd no hace nada pareceria»): a reload that finds the same
552
+ // directory changes no row, so the only way the key can report that it ran is
553
+ // its own token. Like `s sort: …` it names live state — the outcome of the
554
+ // LAST reload — and it never claims a success the reload did not report.
555
+ //
556
+ // The token keeps two spellings: the full one (`r reload failed`) wherever the
557
+ // whole legend fits, and the compact one (`r reloaded`, `r failed`) for the
558
+ // short tier, because the reported ~60-column row has room for the outcome only
559
+ // from `o open`'s cells (see RELOAD_PRIORITY).
560
+ function reloadKey() {
561
+ let text = `${RELOAD_KEY} reload`;
562
+ let short = RELOAD_KEY;
563
+ if (reloadState === 'reloaded') {
564
+ text = `${RELOAD_KEY} reloaded`;
565
+ short = text;
566
+ } else if (reloadState === 'failed') {
567
+ text = `${RELOAD_KEY} reload failed`;
568
+ short = `${RELOAD_KEY} failed`;
569
+ }
570
+ return { key: RELOAD_KEY, text, short, priority: RELOAD_PRIORITY };
571
+ }
572
+
573
+ // S3: ←/→ scroll the list a screenful. There is no page to move to, so the
574
+ // window simply moves over the one list and the focused document follows it
575
+ // into the new screen when it scrolled out of view.
576
+ function scrollList(step) {
577
+ const costs = listCosts();
578
+ if (costs.length === 0) return;
579
+ const budget = Math.max(1, bodyRows);
580
+ const target = layout.shiftWindow(costs, { budget, start: state.listStart, step });
581
+ if (target === state.listStart) return;
582
+ state.listStart = target;
583
+ const win = layout.scrollWindow(costs, { budget, index: target, start: target });
584
+ state.listStart = win.start;
585
+ if (state.index < win.start || state.index >= win.end) {
586
+ state.index = win.start;
587
+ state.scroll = 0;
588
+ ensureEntry(current());
589
+ }
590
+ repaint();
591
+ }
592
+
593
+ // S12: `s` alternates the loader's recency order with a case-insensitive
594
+ // alphabetical one. The focused document is remembered by identity and
595
+ // re-found in the new sequence, so the reader keeps reading the SAME document
596
+ // instead of whatever lands at the old index; the document's own reading
597
+ // position is untouched, because it is still the same document. The list
598
+ // window is re-anchored on the new position so the focus is painted even when
599
+ // its alphabetical place is far from the old one.
600
+ function toggleOrder() {
601
+ const focused = current();
602
+ const focusedKey = focused ? (focused.path || focused.name) : null;
603
+ state.order = state.order === ORDER.NAME ? ORDER.RECENT : ORDER.NAME;
604
+ const list = shown();
605
+ let next = Math.min(state.index, Math.max(0, list.length - 1));
606
+ if (focusedKey !== null) {
607
+ const at = list.findIndex((docEntry) => docEntry && (docEntry.path || docEntry.name) === focusedKey);
608
+ if (at >= 0) next = at;
609
+ }
610
+ state.index = Math.max(0, next);
611
+ const win = layout.scrollWindow(listCosts(), { budget: Math.max(1, bodyRows), index: state.index, start: 0 });
612
+ state.listStart = win.start;
613
+ ensureEntry(current());
614
+ repaint();
615
+ }
616
+
617
+ function render(width) {
618
+ const w = typeof width === 'number' && Number.isFinite(width) ? Math.floor(width) : 0;
619
+ if (w <= 0) return [];
620
+ const height = availableRows();
621
+ const title = titleText();
622
+ if (w < MIN_WIDTH || height < 4) {
623
+ return [pad(helpers, fit(helpers, `${title} — enlarge the terminal`, w), w)].slice(0, Math.max(1, Math.min(height, 1)));
624
+ }
625
+ const list = shown();
626
+ if (state.index >= list.length) state.index = Math.max(0, list.length - 1);
627
+ // S3: the shared 30/70 reader rule owns this geometry, so ODD matches the
628
+ // issues reader with the smaller list index and the larger reading pane.
629
+ // CARD_PADDING covers the frame's own `│ ` and ` │` cells, so this inner
630
+ // width fills the terminal exactly: the right border belongs in the last
631
+ // cell (S24/S27 — no trailing slack, corners and edges stay aligned).
632
+ const inner = layout.innerWidth(w);
633
+ const panes = layout.paneWidths(inner, { listShare: layout.READER_LIST_SHARE });
634
+ const listWidth = panes.left;
635
+ listWidthCache = listWidth;
636
+ const rightWidth = panes.right;
637
+ // S8: the fixed chrome is reserved FIRST — top rule, search row, the ONE
638
+ // footer legend and the bottom rule — and whatever rows remain is the
639
+ // body, so page capacity is the real viewport height, not a guessed 20.
640
+ bodyRows = Math.max(1, height - 4);
641
+ const docEntry = current();
642
+ const entry = docEntry ? ensureEntry(docEntry) : null;
643
+ // The document reflows to the pane width: a resize re-renders it rather
644
+ // than clipping an earlier, wider layout.
645
+ if (entry && entry.status === 'ready' && entry.width !== rightWidth) {
646
+ renderLines(entry, entry.text, rightWidth);
647
+ }
648
+ clampScroll();
649
+ const totalLines = entry && entry.status === 'ready' ? entry.lines.length : 0;
650
+ const border = (text) => paint(theme, 'border', text);
651
+ const divider = border('│');
652
+ const lines = [];
653
+ // S11/S13: the header uses the palette — the title takes the theme's
654
+ // `dim` title role (bold), the `·` separator is the dim label role —
655
+ // while the COUNT alone is muted, so a number never competes with the title
656
+ // for attention and the title never competes with the accent a button owns.
657
+ const parts = titleParts();
658
+ const topLeft = `${border('╭─')} ${palette.title(theme, parts.name)}${palette.label(theme, ' · ')}${palette.count(theme, parts.count)} `;
659
+ // S27: no top-right `esc` text (the /changes frame has none) — the corner
660
+ // and the right border stay in the terminal's last cell.
661
+ const topRight = border('─╮');
662
+ lines.push(`${topLeft}${border('─'.repeat(Math.max(0, w - measure(helpers, topLeft) - measure(helpers, topRight))))}${topRight}`);
663
+
664
+ // S13/S26: one row carries the search widget and the reading position. S4:
665
+ // the key that opens the box lives in the footer now, never in this row, and
666
+ // S3 removed the page marker with the pager. S11: the `›` prompt and the
667
+ // idle `Search` label paint the dim LABEL role while a live query is the
668
+ // VALUE role, in and out of the box.
669
+ const searchText = state.searching
670
+ ? `${palette.label(theme, '› ')}${palette.value(theme, `${state.query}█`)}`
671
+ : (state.query
672
+ ? `${palette.label(theme, '› ')}${palette.value(theme, state.query)}`
673
+ : `${palette.label(theme, '› ')}${palette.label(theme, 'Search')}`);
674
+ // S14: the reading position of an open document keeps the right edge.
675
+ //
676
+ // S10 («pagina/linea ... sale el 1-, esta demas»): the position names how
677
+ // far this screen reaches against the document total. The old
678
+ // `start-end/total` range restated the window's first line — always the line
679
+ // just above the pane, and `1` at the top — so the useful number is kept and
680
+ // the redundant one dropped.
681
+ const visibleThrough = Math.min(totalLines, Math.max(0, state.scroll) + Math.max(1, bodyRows));
682
+ const scrollMark = totalLines > 0 ? `${visibleThrough}/${totalLines}` : '';
683
+ const searchCell = withRight(helpers, searchText, scrollMark, inner);
684
+ lines.push(`${border('│')} ${pad(helpers, searchCell, inner)} ${border('│')}`);
685
+
686
+ const listLines = [];
687
+ if (docs.length === 0) {
688
+ listLines.push(paint(theme, 'muted', EMPTY_LIST));
689
+ listLines.push(paint(theme, 'dim', EMPTY_HINT));
690
+ } else if (list.length === 0) {
691
+ listLines.push(paint(theme, 'muted', '(no matches)'));
692
+ } else {
693
+ // S3/S5/S17: each name wraps over the rows it needs — at spaces and at
694
+ // its own hyphens — and the ONE window paints the documents whose wrapped
695
+ // rows fit the body budget, keeping the focused document inside it.
696
+ const win = listWindow();
697
+ state.listStart = win.start;
698
+ for (let at = win.start; at < win.end; at += 1) {
699
+ const focused = at === state.index;
700
+ const rows = labelRows(String((list[at] && list[at].name) || ''), listWidth, focused ? '▸ ' : ' ', ' ');
701
+ for (const row of rows) {
702
+ listLines.push(highlight(theme, pad(helpers, row, listWidth), focused));
703
+ }
704
+ }
705
+ }
706
+ const contentLines = [];
707
+ if (!docEntry) {
708
+ if (docs.length === 0) {
709
+ contentLines.push(paint(theme, 'muted', EMPTY_LIST));
710
+ contentLines.push(paint(theme, 'dim', EMPTY_HINT));
711
+ } else {
712
+ contentLines.push(paint(theme, 'muted', `(no matches for filter "${state.query}")`));
713
+ }
714
+ } else if (entry.status === 'error') {
715
+ contentLines.push(paint(theme, 'error', `unreadable: ${entry.error}`));
716
+ } else if (entry.status === 'ready' && entry.lines.length === 0) {
717
+ contentLines.push(paint(theme, 'muted', '(empty document)'));
718
+ } else if (entry.status === 'ready') {
719
+ contentLines.push(...entry.lines.slice(state.scroll, state.scroll + bodyRows));
720
+ } else {
721
+ contentLines.push(paint(theme, 'muted', 'loading…'));
722
+ }
723
+ // A failed open is shown, never claimed as success: the error rides
724
+ // above the document so the untouched selection it explains stays visible.
725
+ if (openError) {
726
+ contentLines.unshift(paint(theme, 'error', `open failed: ${openError}`));
727
+ }
728
+ for (let row = 0; row < bodyRows; row += 1) {
729
+ const left = pad(helpers, listLines[row] ?? '', listWidth);
730
+ const right = pad(helpers, contentLines[row] ?? '', rightWidth);
731
+ lines.push(`${border('│')} ${left} ${divider} ${right} ${border('│')}`);
732
+ }
733
+ lines.push(`${border('│')} ${pad(helpers, palette.legendRow(theme, state.searching ? SEARCH_KEYS : contentKeys(), inner, (text) => measure(helpers, text)), inner)} ${border('│')}`);
734
+ lines.push(border(`╰${'─'.repeat(Math.max(0, w - 2))}╯`));
735
+ return lines.map((line) => pad(helpers, line, w));
736
+ }
737
+
738
+ function selectNext(step) {
739
+ const list = shown();
740
+ if (list.length === 0) return;
741
+ state.index = (state.index + step + list.length) % list.length;
742
+ state.scroll = 0;
743
+ ensureEntry(current());
744
+ repaint();
745
+ }
746
+
747
+ // The `T`/`/` name filter (key-consistency K3/S5, same behaviour as
748
+ // the wizard's search box and the issues viewer): typing narrows the
749
+ // list live, Backspace edits, Enter/Esc leaves the box with the filter
750
+ // kept. Anything else is ignored while filtering.
751
+ function handleSearchInput(data) {
752
+ if (keyMatches(helpers, data, ['enter', 'return'])) {
753
+ state.searching = false;
754
+ state.index = 0;
755
+ state.listStart = 0;
756
+ state.scroll = 0;
757
+ repaint();
758
+ return true;
759
+ }
760
+ if (keyMatches(helpers, data, ['escape'])) {
761
+ state.searching = false;
762
+ state.index = 0;
763
+ state.listStart = 0;
764
+ repaint();
765
+ return true;
766
+ }
767
+ if (keyMatches(helpers, data, ['backspace'])) {
768
+ state.query = state.query.slice(0, -1);
769
+ state.index = 0;
770
+ state.listStart = 0;
771
+ state.scroll = 0;
772
+ repaint();
773
+ return true;
774
+ }
775
+ // S4: Delete clears the WHOLE query while the search input is active;
776
+ // Backspace above keeps deleting one character. The box stays open so a
777
+ // new query needs no extra keypress.
778
+ if (keyMatches(helpers, data, ['delete'])) {
779
+ state.query = '';
780
+ state.index = 0;
781
+ state.listStart = 0;
782
+ state.scroll = 0;
783
+ repaint();
784
+ return true;
785
+ }
786
+ const typed = typeof data === 'string' ? data : (data && (data.name || data.key)) || '';
787
+ if (typeof typed === 'string' && [...typed].length === 1) {
788
+ state.query += typed;
789
+ state.index = 0;
790
+ state.listStart = 0;
791
+ state.scroll = 0;
792
+ repaint();
793
+ return true;
794
+ }
795
+ return false;
796
+ }
797
+
798
+ function handleInput(data) {
799
+ if (isKeyRelease(helpers, data)) return;
800
+ // The wheel can also arrive as raw SGR/X10 bytes on hosts that do not
801
+ // dispatch mouse events: both paths scroll the preview, never the list.
802
+ const wheel = wheelDeltaFromInput(data);
803
+ if (wheel !== undefined) {
804
+ scrollBy(wheel);
805
+ return;
806
+ }
807
+ // While filtering, only the search keys answer: Enter/Esc leaves the
808
+ // box with the filter kept, typing narrows live. Everything else —
809
+ // arrows, toggles, `o` — is ignored until the box is left.
810
+ if (state.searching) {
811
+ if (handleSearchInput(data)) return;
812
+ return;
813
+ }
814
+ // S1: the shared binding helper owns the preview scroll keys, so this
815
+ // handler and the footer legend can never disagree about which pair is
816
+ // live. ctrl+j/k page wherever the host reports them apart from Enter,
817
+ // ctrl+shift+j/k and the printable J/K are working alternates, ctrl+d/u
818
+ // keep their half-page step, and bare LF/ctrl+j is not a scroll key — a
819
+ // legacy terminal sends it for Enter too.
820
+ const scrollAction = scrollKeys.resolveScrollAction(helpers, data);
821
+ if (scrollAction) {
822
+ const page = Math.max(1, bodyRows - 1);
823
+ const halved = scrollAction === scrollKeys.ACTIONS.HALF_DOWN || scrollAction === scrollKeys.ACTIONS.HALF_UP;
824
+ const step = halved ? Math.max(1, Math.floor(page / 2)) : page;
825
+ const down = scrollAction === scrollKeys.ACTIONS.PAGE_DOWN || scrollAction === scrollKeys.ACTIONS.HALF_DOWN;
826
+ scrollBy(down ? step : -step);
827
+ return;
828
+ }
829
+ if (keyMatches(helpers, data, ['escape', 'ctrl+c', 'q'])) {
830
+ if (typeof done === 'function') done(null);
831
+ return;
832
+ }
833
+ // `t`/`T` or `/` opens the name filter on the current query, for editing.
834
+ if (keyMatches(helpers, data, ['t', 'T', '/'])) {
835
+ state.searching = true;
836
+ repaint();
837
+ return;
838
+ }
839
+ // S12: `s` alternates the list order (recency ↔ alphabetical). The filter
840
+ // box returns above, so the letter stays a query character while it is
841
+ // open — the order never flips under someone typing.
842
+ if (keyMatches(helpers, data, [ORDER_KEY])) {
843
+ toggleOrder();
844
+ return;
845
+ }
846
+ // `o` opens the focused document outside Pi through $VISUAL/$EDITOR:
847
+ // the injected opener reports honestly, so a missing editor or a
848
+ // failed spawn shows its error instead of failing silently.
849
+ if (keyMatches(helpers, data, ['o'])) {
850
+ const focused = current();
851
+ if (!focused) return;
852
+ if (typeof openExternal !== 'function') {
853
+ openError = 'the viewer has no external opener';
854
+ repaint();
855
+ return;
856
+ }
857
+ const target = (typeof focused.path === 'string' && focused.path.length > 0)
858
+ ? focused.path
859
+ : focused.name;
860
+ let result = null;
861
+ try {
862
+ result = openExternal(target);
863
+ } catch (err) {
864
+ result = { ok: false, error: (err && err.message) || 'open failed' };
865
+ }
866
+ Promise.resolve(result).then((settled) => {
867
+ if (settled && settled.ok === true) openError = null;
868
+ else openError = (settled && settled.error) || 'open failed';
869
+ repaint();
870
+ }).catch((err) => {
871
+ openError = (err && err.message) || 'open failed';
872
+ repaint();
873
+ });
874
+ return;
875
+ }
876
+ if (keyMatches(helpers, data, ['down', 'j'])) {
877
+ selectNext(1);
878
+ return;
879
+ }
880
+ if (keyMatches(helpers, data, ['up', 'k'])) {
881
+ selectNext(-1);
882
+ return;
883
+ }
884
+ // D3: left/right scroll the ACTIVE list and nothing else (S3: a screenful of
885
+ // the one list, never a page); Tab never touches a single-dataset reader.
886
+ if (keyMatches(helpers, data, ['right', ']'])) {
887
+ scrollList(1);
888
+ return;
889
+ }
890
+ if (keyMatches(helpers, data, ['left', '['])) {
891
+ scrollList(-1);
892
+ return;
893
+ }
894
+ if (keyMatches(helpers, data, ['end'])) {
895
+ state.scroll = Number.MAX_SAFE_INTEGER;
896
+ clampScroll();
897
+ repaint();
898
+ return;
899
+ }
900
+ if (keyMatches(helpers, data, ['home'])) {
901
+ state.scroll = 0;
902
+ repaint();
903
+ return;
904
+ }
905
+ // S9: `r` re-lists AND re-reads. The listing is refreshed from disk, every
906
+ // cached document is dropped so the open one is read again (an edit cannot
907
+ // hide behind a cached body), and the outcome rides the footer token so a
908
+ // reload that changes no row is still visible. A reload that cannot re-read
909
+ // the directory keeps the documents it already has and reports the failure
910
+ // instead of appearing to do nothing. S21: `reloadDocs` may answer a
911
+ // PROMISE — the interactive caller re-lists through the awaited Git reader,
912
+ // so a slow history walk is never a blocking read inside this key handler
913
+ // (the view keeps painting and the outcome lands when the listing does).
914
+ if (keyMatches(helpers, data, [RELOAD_KEY])) {
915
+ if (typeof reloadDocs !== 'function') return;
916
+ let result = null;
917
+ try {
918
+ result = reloadDocs();
919
+ } catch {
920
+ reloadState = 'failed';
921
+ repaint();
922
+ return;
923
+ }
924
+ const settle = (next) => {
925
+ if (!next || next.ok !== true || !Array.isArray(next.docs)) {
926
+ reloadState = 'failed';
927
+ repaint();
928
+ return;
929
+ }
930
+ docs = next.docs.slice();
931
+ entries.clear();
932
+ reloadState = 'reloaded';
933
+ // The filter survives a reload: clamp the selection into the
934
+ // still-visible list, exactly like the issues viewer keeps its query.
935
+ if (state.index >= shown().length) state.index = Math.max(0, shown().length - 1);
936
+ state.scroll = 0;
937
+ ensureEntry(current());
938
+ repaint();
939
+ };
940
+ if (result && typeof result.then === 'function') {
941
+ Promise.resolve(result).then(settle).catch(() => {
942
+ reloadState = 'failed';
943
+ repaint();
944
+ });
945
+ } else {
946
+ settle(result);
947
+ }
948
+ }
949
+ }
950
+
951
+ function invalidate() {}
952
+
953
+ // S21: the list can be handed to an OPEN view a second time — the command
954
+ // paints the cheap local scan at once and applies the git-ranked order when
955
+ // the checkout's history walk answers, so the viewer never waits on git to
956
+ // appear and a slow walk never freezes it. This behaves like the `s` order
957
+ // toggle, never like a reload: the focused document is re-found by IDENTITY
958
+ // (the reader keeps reading the same file instead of whatever lands at the old
959
+ // index), the window re-anchors on its new position, and the rendered
960
+ // documents are KEPT — only the order moved, so re-reading them would cost the
961
+ // reader the pane they are already reading (and could only surface a
962
+ // difference the settled order never claimed). An answer that is not a list
963
+ // leaves the open view exactly as it is.
964
+ function applyDocs(next) {
965
+ if (!Array.isArray(next)) return;
966
+ const focused = current();
967
+ const focusedKey = focused ? (focused.path || focused.name) : null;
968
+ docs = next.slice();
969
+ const list = shown();
970
+ let target = Math.min(state.index, Math.max(0, list.length - 1));
971
+ if (focusedKey !== null) {
972
+ const at = list.findIndex((docEntry) => docEntry && (docEntry.path || docEntry.name) === focusedKey);
973
+ if (at >= 0) target = at;
974
+ }
975
+ state.index = Math.max(0, target);
976
+ const win = layout.scrollWindow(listCosts(), { budget: Math.max(1, bodyRows), index: state.index, start: 0 });
977
+ state.listStart = win.start;
978
+ ensureEntry(current());
979
+ repaint();
980
+ }
981
+
982
+ // The selected document starts loading as soon as the view exists, so the
983
+ // first frame the host paints already carries content instead of a
984
+ // placeholder.
985
+ ensureEntry(current());
986
+
987
+ return { render, handleInput, invalidate, handleMouse, applyDocs, state };
988
+ }
989
+
990
+ module.exports = { createOddView, CONTENT_KEYS, SEARCH_KEYS, EMPTY_LIST, EMPTY_HINT };