pi-odd-worktree 0.0.0-stage → 0.1.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 +223 -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,2274 @@
1
+ 'use strict';
2
+ // Issues viewer component, migrated from the source package's issues panel
3
+ // (T2 S1,S3): a framed full-screen four-bucket overlay (My issues / Assigned /
4
+ // Involved / All) with a live search box, ONE continuously scrolling list, and
5
+ // a scrolling issue pane (labels, state, author, dates, reactions, separated
6
+ // comments). `/issues` mounts it once that command is wired (next unit); the
7
+ // panel's behavior is here whole, nothing trimmed in the move.
8
+ //
9
+ // PURE: no `gh`, no registry, no files. Listing rows arrive either eagerly
10
+ // as `pages` / `issues` (unchanged legacy path) or lazily through
11
+ // `loadPages({state})`, the detail arrives through `loadIssue(number)`, repo
12
+ // labels through `loadLabels()` on the editor's first use, and writes go
13
+ // through `actions.{editLabels,postComment,setState}` — every failure is
14
+ // shown and keeps the local state unchanged. Same host contract as
15
+ // lib/odd-view.js: { render(width), handleInput(data), invalidate() } plus
16
+ // `state`.
17
+ //
18
+ // S16/D2: label and assignee editing is a repository capability, never the
19
+ // fact that the user authenticated. Issue detail carries
20
+ // `permissions: {canEditLabels, canEditAssignees, canCreateLabels,
21
+ // canCloseIssues}`; anything but a literal `true` (missing, unknown,
22
+ // non-boolean) is false, so the footer hint is absent AND the key cannot enter
23
+ // the editor. Applying existing labels needs `canEditLabels`, creating one
24
+ // needs the distinct WRITE capability `canCreateLabels` (a triage-only viewer
25
+ // is offered neither the create row nor its key), closing/reopening needs
26
+ // `canCloseIssues`, and assignees need `canEditAssignees` and save as
27
+ // add/remove deltas through `actions.editAssignees`. Commenting needs no
28
+ // capability beyond read access, so `c` stays ungated. A rejected save stays
29
+ // visible with the local toggles intact; a successful one refetches the detail
30
+ // through `loadIssue(number, {refresh:true})` instead of trusting the cache, and
31
+ // `r` re-reads that detail so a permission is never a stale capability.
32
+ //
33
+ // S10b: the view is read-only with respect to the checkout↔issue
34
+ // association. The `assigned`, `saveAssignment` and `assignmentEnabled`
35
+ // options a caller may still pass are accepted and ignored — no mark, no
36
+ // toggle, no save — and no worktree association is rendered here.
37
+ //
38
+ // S19: the loader seam is what lets a host open this view immediately and
39
+ // paint an honest loading state instead of awaiting every network read. The
40
+ // S18 corrections live here too, and S10a narrows them: the view lists OPEN
41
+ // issues only, so every read asks for `open` — there is no closed state, no
42
+ // toggle and no combined view — and each tab names the count of open issues
43
+ // it actually lists.
44
+
45
+ //
46
+ // S3/S5/D1 (this unit): the left list carries the full wrapped title and no
47
+ // issue number, and its pane takes the shared reader-width rule from
48
+ // lib/view-layout.js instead of the old 34/36-column cap. Paging is a
49
+ // server-page seam — `loadIssuePage({bucketId, cursor, limit, query})`
50
+ // answering `{ok, issues, total, hasNextPage, error?}`, every issue carrying
51
+ // its server `cursor` — where `limit` is the real body-row budget (never a
52
+ // fixed count) and the next request continues after the LAST loaded row. The
53
+ // eager `pages` / `loadPages` contracts stay supported as the fallback.
54
+ //
55
+ // S3/T3 (this unit): the list is ONE continuous window, never a pager. The
56
+ // left pane takes the smaller 30% share (`layout.READER_LIST_SHARE`), `]`/`[`
57
+ // scroll the list a screenful instead of moving to a page, and the focused row
58
+ // always stays inside the painted window. On the server seam the window reads
59
+ // CONTINUOUSLY: reaching the bottom of the rows loaded so far appends the next
60
+ // cursor page below them, so the list keeps going while one page per bucket is
61
+ // still what opening costs. Every read still carries `query`, so the search
62
+ // keeps reading the complete bucket (S9) instead of the rows on screen.
63
+ //
64
+ // S10/S11 (this unit): `←`/`→` switch buckets exactly like `tab` — scrolling a
65
+ // tabbed list with the arrows never made sense, and the list keeps `]`/`[` — and
66
+ // every footer/header token paints through lib/tui-paint.js, so a shortcut KEY
67
+ // (a button) is accent+bold, a TITLE takes `customMessageLabel` — the role the Pi
68
+ // Agents view paints its frame title with —
69
+ // ordinary values are the normal text role and no view hardcodes a colour.
70
+ //
71
+ // S13/S14/S15 (this unit): a tab that was already read paints its rows and its
72
+ // own scroll position again instead of refetching and flashing `loading` — `r`
73
+ // is the explicit refresh of the list the reader is on. The buckets end with
74
+ // `All`, the whole open repository with no dedup against the other tabs (the
75
+ // caller supplies its query; the view never invents one).
76
+ //
77
+ // S9 (this unit): the search box is a SERVER search on the page seam. Every
78
+ // request carries `query`, so the seam searches the complete bucket instead of
79
+ // the loaded page, and its rows are never re-filtered locally as literal text
80
+ // (GitHub operators such as `label:` are not title words). A new query reads
81
+ // from the head, cursors stay bound to the query that produced them, and a
82
+ // successful write invalidates the loaded pages it changed. Static and
83
+ // `loadPages` callers keep the local filter they always had.
84
+ //
85
+ // S10/S11 (this unit): every write (labels, assignees, comment, close) records
86
+ // and paints its busy state BEFORE the action is invoked and ignores every key
87
+ // until it settles, so a second save, a page/tab move or typing can never race
88
+ // it; a failure keeps the draft and names the real error. The comment editor
89
+ // paints the caret at the actual insertion point.
90
+
91
+ // S9: a remote search is debounced, so a burst of typing costs ONE server read
92
+ // for the query it settles on. The delay is an ordinary short choice; the clock
93
+ // itself is injected (`scheduleQuery` / `cancelSchedule`), so a host or a test
94
+ // owns it and no expectation depends on wall-clock timing.
95
+ const QUERY_DEBOUNCE_MS = 180;
96
+
97
+ function defaultSchedule(fn, delayMs) {
98
+ return setTimeout(fn, delayMs);
99
+ }
100
+
101
+ function defaultCancel(handle) {
102
+ clearTimeout(handle);
103
+ }
104
+
105
+ const layout = require('./view-layout.js');
106
+ const scrollKeys = require('./preview-scroll-keys.js');
107
+ // S11: the ONE palette the readers share. Every footer key, footer description,
108
+ // frame title, tab count, static label and live search value paints through it,
109
+ // so a shortcut key is always distinguishable from the words beside it and no
110
+ // view keeps its own copy of the hostile-theme guards.
111
+ const palette = require('./tui-paint.js');
112
+ const paint = palette.paint;
113
+ const emphasize = palette.emphasize;
114
+ const highlight = palette.highlight;
115
+ // S16: the ONE issue preview, migrated whole as `lib/issue-preview.js`. The
116
+ // Issues pane calls it instead of keeping a second copy of the same fields — a
117
+ // preview that drifts surface by surface is what S16 reported. S12 adds the
118
+ // markdown render for the preview's FREE TEXT only: the same pure renderer the
119
+ // ODD document pane uses (`lib/odd-markdown.js`).
120
+ const { issueDetailLines, defaultWrapCache } = require('./issue-preview.js');
121
+ const { renderOddMarkdown } = require('./odd-markdown.js');
122
+
123
+ const CARD_PADDING = layout.CARD_PADDING;
124
+ const MIN_WIDTH = 24;
125
+ // The buckets the server seam reads from unless the caller passes its own
126
+ // `pages` metadata (ids and labels are then the caller's) — S15 appends `All`,
127
+ // the whole open repository, and never deduplicates it against the others.
128
+ const DEFAULT_BUCKETS = [
129
+ { id: 'mine', label: 'My issues' },
130
+ { id: 'assigned', label: 'Assigned' },
131
+ { id: 'involved', label: 'Involved' },
132
+ { id: 'all', label: 'All' },
133
+ ];
134
+ // S17: no `? help` — the footer legend is the only key reference. Its contents
135
+ // stay fixed (S10b: no assignment key rides it) and S13 keeps it free of
136
+ // repetition: every shortcut, the search key included, is named here once and
137
+ // the state toggle is gone (S10a). S2: it is ONE terminal row — `text`
138
+ // is the full token, `short` the compact form used when the full one does not
139
+ // fit, and `priority` decides what a still-too-wide legend drops first (lowest
140
+ // first), so `esc close` outlives every other hint.
141
+ const FOOTER_KEYS = [
142
+ { key: '↑↓', text: '↑↓ move', short: '↑↓ move', priority: 90 },
143
+ // S10: the arrows switch buckets exactly like `tab` — a scroll on ←/→ made no
144
+ // sense in a tabbed reader — and the list keeps its other scroll keys, `]`/`[`.
145
+ // `tab` stays a working binding but leaves the row: the arrows already say it.
146
+ { key: '←→', text: '←→ bucket', short: '←→', priority: 62 },
147
+ // S14: the document token is the only scroll hint on the row, so it can
148
+ // simply say what it does. `]`/`[` still scroll the list; the hint was noise
149
+ // because selection already drags the list window, so it left the row.
150
+ // S16: an editor entry carries the capability it needs, and the render
151
+ // drops the whole hint when that capability is not confirmed true.
152
+ { key: 'L', text: 'L labels', short: 'L', priority: 50, requires: 'canEditLabels' },
153
+ { key: 'a', text: 'a assign', short: 'a', priority: 48, requires: 'canEditAssignees' },
154
+ { key: 'c', text: 'c comment', short: 'c', priority: 45 },
155
+ // Closing (and reopening) is a TRIAGE-level repository capability like the two
156
+ // editors above, so its hint disappears with it. Only the lowercase key
157
+ // rides the legend; `X` keeps closing without a token.
158
+ { key: 'x', text: 'x close', short: 'x', priority: 40, requires: 'canCloseIssues' },
159
+ { key: 'o', text: 'o web', short: 'o', priority: 20 },
160
+ // S4: the search key belongs here with every other shortcut, never on the
161
+ // search row above it. The legend spells the lowercase key `t` (the one a
162
+ // reader reaches for); `T` keeps opening the box without a token.
163
+ { key: 't', text: 't search', short: 't', priority: 35 },
164
+ { key: 'esc', text: 'esc close', short: 'esc close', priority: 100 },
165
+ ];
166
+
167
+ // S13: while the search box holds the keyboard, the footer names THAT
168
+ // interaction once — enter and esc each keep the filter — instead of the box
169
+ // and the footer both spelling the same keys out. `enter/esc keep` is a single
170
+ // token so the two keys never inflate the ONE legend row (S2).
171
+ const SEARCH_KEYS = [
172
+ { key: 'type', text: 'type to filter', short: 'type', priority: 30 },
173
+ { key: 'bksp', text: 'bksp edit', short: 'bksp', priority: 20 },
174
+ // S4: Delete clears the whole query, so its hint is part of the typing
175
+ // interaction and leaves with it.
176
+ { key: 'del', text: 'del clear', short: 'del', priority: 50 },
177
+ { key: 'enter/esc', text: 'enter/esc keep', short: 'enter/esc', priority: 60 },
178
+ ];
179
+
180
+ // Printable paging keys must not fall through to lowercase list selection.
181
+ const SHIFTED_TWINS = new Set(['j', 'k']);
182
+
183
+ // One page entry, whatever the source: eagerly supplied `pages` and every
184
+ // `loadPages`/`reload` result pass through here so both paths share one shape.
185
+ function mapPage(page, at) {
186
+ return {
187
+ id: page && page.id != null ? String(page.id) : `p${at}`,
188
+ label: page && page.label ? String(page.label) : `Page ${at + 1}`,
189
+ issues: Array.isArray(page && page.issues) ? page.issues.slice() : null,
190
+ total: page && Number.isFinite(Number(page.total)) ? Number(page.total) : null,
191
+ truncated: !!(page && page.truncated),
192
+ };
193
+ }
194
+
195
+ function stripAnsi(text) {
196
+ return String(text).replace(/\x1b\[[0-9;]*m/g, '');
197
+ }
198
+
199
+ function measure(helpers, text) {
200
+ try {
201
+ if (helpers && typeof helpers.visibleWidth === 'function') {
202
+ const size = Number(helpers.visibleWidth(text));
203
+ if (Number.isFinite(size)) return size;
204
+ }
205
+ } catch {
206
+ // Fall through to the plain count.
207
+ }
208
+ return [...stripAnsi(text)].length;
209
+ }
210
+
211
+ function fit(helpers, text, width) {
212
+ const clean = String(text).split('\n')[0] ?? '';
213
+ if (measure(helpers, clean) <= width) return clean;
214
+ try {
215
+ if (helpers && typeof helpers.truncateToWidth === 'function') {
216
+ const cut = helpers.truncateToWidth(clean, width, '…');
217
+ if (typeof cut === 'string') return cut;
218
+ }
219
+ } catch {
220
+ // Fall through to the plain slice.
221
+ }
222
+ const chars = [...clean];
223
+ return chars.length > width ? chars.slice(0, Math.max(0, width - 1)).join('') + '…' : clean;
224
+ }
225
+
226
+ function pad(helpers, text, width) {
227
+ const clipped = fit(helpers, text, width);
228
+ return clipped + ' '.repeat(Math.max(0, width - measure(helpers, clipped)));
229
+ }
230
+
231
+ // S14: the last cells of a query that fit `room`, prefixed with an ellipsis when
232
+ // something was dropped. Only the DISPLAY is shortened — the stored query keeps
233
+ // every character — and keeping the tail means the caret (where typing lands)
234
+ // stays on screen.
235
+ function tailOf(helpers, text, room) {
236
+ const chars = [...String(text ?? '')];
237
+ if (room <= 0) return '';
238
+ if (measure(helpers, chars.join('')) <= room) return chars.join('');
239
+ let cut = '';
240
+ for (let at = chars.length - 1; at >= 0; at -= 1) {
241
+ const next = chars[at] + cut;
242
+ if (measure(helpers, `…${next}`) > room) break;
243
+ cut = next;
244
+ }
245
+ return cut === '' ? '…' : `…${cut}`;
246
+ }
247
+
248
+ // S2: a key legend stays on ONE row. The full text is used while it fits;
249
+ // otherwise the legend compacts to its short tokens and then drops its
250
+ // lowest-priority keys (display order never changes, and no token carries a
251
+ // newline), so it never wraps onto a second row. The caller still pads, which
252
+ // keeps the row cell-exact if a single token is wider than the card. The
253
+ // compaction and the role painting live in lib/tui-paint.js (S11), so the
254
+ // painted row and the measured one can never disagree.
255
+
256
+ function keyMatches(helpers, data, names) {
257
+ for (const name of names) {
258
+ try {
259
+ if (helpers && typeof helpers.matchesKey === 'function' && helpers.matchesKey(data, name)) return true;
260
+ } catch {
261
+ // A throwing matcher never matches; the string fallback decides below.
262
+ }
263
+ }
264
+ const text = typeof data === 'string' ? data : (data && (data.name || data.key)) || '';
265
+ return names.some((name) => sameKeyName(text, name));
266
+ }
267
+
268
+ // Key ids are loose about case because hosts spell them differently
269
+ // (`pageDown` vs `pagedown`); only a shifted letter whose unshifted twin is a
270
+ // live binding (`J` next to `j`) stays case-sensitive.
271
+ function sameKeyName(left, right) {
272
+ const a = String(left);
273
+ const b = String(right);
274
+ if (a === b) return true;
275
+ const shiftedLetter = (text) => text.length === 1 && text >= 'A' && text <= 'Z';
276
+ if (shiftedLetter(a) && !shiftedLetter(b) && SHIFTED_TWINS.has(b.toLowerCase())) return false;
277
+ return a.toLowerCase() === b.toLowerCase();
278
+ }
279
+
280
+ function isKeyRelease(helpers, data) {
281
+ try {
282
+ if (helpers && typeof helpers.isKeyRelease === 'function') return helpers.isKeyRelease(data) === true;
283
+ } catch {
284
+ // A throwing classifier never releases; normal handling follows.
285
+ }
286
+ return false;
287
+ }
288
+
289
+ function labelName(label) {
290
+ if (typeof label === 'string') return label;
291
+ if (label && typeof label.name === 'string') return label.name;
292
+ return '';
293
+ }
294
+
295
+ function assigneeName(entry) {
296
+ if (typeof entry === 'string') return entry;
297
+ if (entry && typeof entry.login === 'string') return entry.login;
298
+ return '';
299
+ }
300
+
301
+ // S16: one capability contract for both editors, plus the close/reopen write.
302
+ // Only a literal `true` confirms a permission, so a missing field, an unknown
303
+ // string or a truthy non-boolean all read as "not permitted" (GitHub stays the
304
+ // final authority).
305
+ const NO_PERMISSIONS = {
306
+ canEditLabels: false,
307
+ canEditAssignees: false,
308
+ canCreateLabels: false,
309
+ canCloseIssues: false,
310
+ };
311
+
312
+ function issuePermissions(issue) {
313
+ const raw = issue && typeof issue === 'object' ? issue.permissions : null;
314
+ const confirmed = (name) => Boolean(raw && typeof raw === 'object' && raw[name] === true);
315
+ return {
316
+ canEditLabels: confirmed('canEditLabels'),
317
+ canEditAssignees: confirmed('canEditAssignees'),
318
+ canCreateLabels: confirmed('canCreateLabels'),
319
+ canCloseIssues: confirmed('canCloseIssues'),
320
+ };
321
+ }
322
+
323
+ // One assignable user as the editor lists it: a login plus the display name
324
+ // GitHub may or may not carry.
325
+ function assigneeOption(user) {
326
+ if (typeof user === 'string') return user === '' ? null : { login: user, name: '' };
327
+ const login = user && typeof user.login === 'string' ? user.login : '';
328
+ if (login === '') return null;
329
+ return { login, name: user && typeof user.name === 'string' ? user.name : '' };
330
+ }
331
+
332
+ function wrapText(text, width) {
333
+ const out = [];
334
+ for (const paragraph of String(text ?? '').split('\n')) {
335
+ if (paragraph.trim() === '') {
336
+ out.push('');
337
+ continue;
338
+ }
339
+ const words = paragraph.split(/\s+/).filter(Boolean);
340
+ let line = '';
341
+ for (const word of words) {
342
+ if (line === '') {
343
+ line = word.length > width ? word.slice(0, width) : word;
344
+ } else if ((line + ' ' + word).length <= width) {
345
+ line += ' ' + word;
346
+ } else {
347
+ out.push(line);
348
+ line = word.length > width ? word.slice(0, width) : word;
349
+ }
350
+ }
351
+ out.push(line);
352
+ }
353
+ return out.length === 0 ? [''] : out;
354
+ }
355
+
356
+ function matchesQuery(row, query) {
357
+ const tokens = String(query || '').split(/\s+/).map((t) => t.trim()).filter(Boolean);
358
+ if (tokens.length === 0) return true;
359
+ const title = String(row.title || '').toLowerCase();
360
+ const author = String(row.author || '').toLowerCase();
361
+ const number = String(row.number ?? '');
362
+ return tokens.every((token) => {
363
+ if (token.startsWith('@')) {
364
+ const want = token.slice(1).toLowerCase();
365
+ return want === '' || author.includes(want);
366
+ }
367
+ if (token.startsWith('#')) {
368
+ const want = token.slice(1);
369
+ return want === '' || number.includes(want);
370
+ }
371
+ return title.includes(token.toLowerCase());
372
+ });
373
+ }
374
+
375
+ function normalizeDetail(result) {
376
+ if (!result || typeof result !== 'object') return { ok: false, error: 'issue unreadable' };
377
+ if (result.ok === true && result.issue && typeof result.issue === 'object') {
378
+ return { ok: true, issue: result.issue, comments: Array.isArray(result.comments) ? result.comments : [] };
379
+ }
380
+ if (result.ok === false) return { ok: false, error: String(result.error || 'issue unreadable') };
381
+ return { ok: false, error: 'issue unreadable' };
382
+ }
383
+
384
+ // S12: the exact strings `lib/issue-preview.js` treats as FREE TEXT — the body
385
+ // (or the `(no description)` placeholder it substitutes) and every comment
386
+ // body. Only those take the markdown path; a row the formatter generates itself
387
+ // (number, title, state, labels, counts, author, ages) never does, so a `#` in
388
+ // a title or a `-` in a label list stays literal text.
389
+ function markdownBodySources(issue, comments) {
390
+ const sources = new Set();
391
+ const body = issue && typeof issue.body === 'string' ? issue.body : '';
392
+ sources.add(body.length > 0 ? body : '(no description)');
393
+ for (const comment of comments) {
394
+ sources.add(comment && typeof comment.body === 'string' ? comment.body : '');
395
+ }
396
+ return sources;
397
+ }
398
+
399
+ function createIssuesView(options = {}) {
400
+ const {
401
+ helpers = null,
402
+ theme = null,
403
+ rowsAvailable = null,
404
+ requestRender = null,
405
+ done = null,
406
+ loadIssue = null,
407
+ loadPages = null,
408
+ loadIssuePage = null,
409
+ loadLabels = null,
410
+ loadAssignees = null,
411
+ actions = {},
412
+ // S9: the remote-search debounce clock. Hosts and tests inject it; the
413
+ // defaults are the ordinary timers.
414
+ scheduleQuery = null,
415
+ cancelSchedule = null,
416
+ } = options;
417
+
418
+ // S25: the header repeats neither the branch nor the worktree ("solo data
419
+ // relevante y lo mas abreviada posible"); `options.checkoutName` is still
420
+ // accepted for compatibility and deliberately never rendered.
421
+
422
+ // Loader mode: the caller fetches issues for the filter the viewer asks
423
+ // for. Eager mode keeps the previous contract intact for callers that
424
+ // already hand over `pages`.
425
+ const loadPageFn = typeof loadPages === 'function' ? loadPages : null;
426
+ // S5/D1: the server-page seam is the preferred reader; the older
427
+ // `loadPages` contract is only the fallback when it is absent.
428
+ const pageLoaderFn = typeof loadIssuePage === 'function' ? loadIssuePage : null;
429
+ const serverMode = pageLoaderFn !== null;
430
+ const loaderMode = loadPageFn !== null && !serverMode;
431
+ const scheduleFn = typeof scheduleQuery === 'function' ? scheduleQuery : defaultSchedule;
432
+ const cancelFn = typeof cancelSchedule === 'function' ? cancelSchedule : defaultCancel;
433
+
434
+ // S10b: `options.assigned`, `options.saveAssignment` and
435
+ // `options.assignmentEnabled` are deliberately not read: no worktree
436
+ // association exists here, and this view stays read-only.
437
+
438
+ let pages = Array.isArray(options.pages) && options.pages.length > 0
439
+ ? options.pages.map(mapPage)
440
+ : (serverMode
441
+ ? DEFAULT_BUCKETS.map((bucket, at) => mapPage({ id: bucket.id, label: bucket.label, issues: null }, at))
442
+ : (loaderMode ? [] : [{ id: 'mine', label: 'My issues', issues: null, total: null }]));
443
+ let fallbackIssues = Array.isArray(options.issues) ? options.issues.slice() : [];
444
+ let repoLabels = Array.isArray(options.labels)
445
+ ? options.labels.map((label) => ({ name: labelName(label) })).filter((label) => label.name)
446
+ : [];
447
+
448
+ const state = {
449
+ page: 0,
450
+ index: 0,
451
+ // S3: the first painted row of the list window. There is no page to move to
452
+ // — `]`/`[` scroll this window and the focused row follows it (S10).
453
+ listStart: 0,
454
+ scroll: 0,
455
+ query: '',
456
+ searching: false,
457
+ mode: 'list',
458
+ loading: loaderMode || serverMode,
459
+ loadError: null,
460
+ labelIndex: 0,
461
+ labelCreating: false,
462
+ createName: '',
463
+ assigneeIndex: 0,
464
+ commentText: '',
465
+ confirm: null,
466
+ discardAsk: false,
467
+ // S10: the write in flight, if any. While it is set the view owns the
468
+ // keyboard and every key is ignored.
469
+ saving: null,
470
+ // S9: a remote search read is scheduled but not yet issued. The query is
471
+ // already applied; only its server read is waiting for the debounce.
472
+ searchPending: false,
473
+ };
474
+ let bodyRows = 1;
475
+ // The interactive reader wants every tab's data at open (S13/S15 round 3):
476
+ // switching must never refetch and no tab may sit at a false zero.
477
+ const prefetchBuckets = Boolean(options) && options.prefetchBuckets === true;
478
+ const details = new Map();
479
+ // S12: the markdown render of one body or comment, memoized on the exact
480
+ // source and pane width for the same reason the plain wrap is (S23): a
481
+ // repaint at an unchanged width must not re-render every comment on screen.
482
+ // Both halves of the key are the renderer's whole input, so a hit can only
483
+ // return rows the same source already produced; the memo lives per view, so
484
+ // a theme or helper change can never be served a stale paint.
485
+ const markdownMemo = new Map();
486
+ const MARKDOWN_MEMO_ENTRIES = 64;
487
+ let actionError = null;
488
+ // Only the newest page request may apply its result (S18/S19: a slow answer
489
+ // must never overwrite a newer one), and a closed view repaints nothing.
490
+ let active = true;
491
+ let pagesSeq = 0;
492
+ // Every server read carries a monotonic generation, so only the newest answer
493
+ // for a bucket may paint (D1) and a superseded append is dropped.
494
+ let pageSeq = 0;
495
+ let listWidthCache = 20;
496
+ // S9: the ONE scheduled remote read. Replacing it cancels the previous one,
497
+ // so query changes never accumulate timers or late answers.
498
+ let scheduledQuery = null;
499
+ let labelsLoaded = false;
500
+ let labelsLoading = false;
501
+ let labelsError = null;
502
+ // S16: assignable users load on the assignee editor's FIRST use, like repo
503
+ // labels, and a failed load stays retryable on the next open.
504
+ let assigneeUsers = [];
505
+ let assigneesLoaded = false;
506
+ let assigneesLoading = false;
507
+ let assigneesError = null;
508
+
509
+ const numOf = (row) => (row && row.number != null ? Number(row.number) : NaN);
510
+
511
+ // A one-shot result line for an action that finished (S6: a posted
512
+ // comment). It is dropped by the next key and never outlives the view.
513
+ let notice = null;
514
+
515
+ function baseIssues() {
516
+ const current = pages[state.page];
517
+ if (current && Array.isArray(current.issues)) return current.issues;
518
+ // On the server seam a bucket holds only the rows its own read returned, so
519
+ // a bucket with no rows is EMPTY — never the eager fallback list, which
520
+ // answers no request this bucket made.
521
+ return serverMode ? [] : fallbackIssues;
522
+ }
523
+
524
+ function filteredIssues() {
525
+ // S10a: open issues only. The row's own state decides, so a page that
526
+ // arrives with a closed row never lists it, whatever the fetch claimed.
527
+ const open = baseIssues().filter((row) => rowState(row) === 'open');
528
+ // S9: on the server seam the query WAS the request, so the answer is the
529
+ // complete result for it and re-filtering it as literal title text would
530
+ // drop every row GitHub matched for an operator (`label:`, `is:`, ...).
531
+ if (serverMode) return open;
532
+ return open.filter((row) => matchesQuery(row, state.query));
533
+ }
534
+
535
+ function clampSelection() {
536
+ const total = filteredIssues().length;
537
+ if (state.index >= total) state.index = Math.max(0, total - 1);
538
+ if (state.index < 0) state.index = 0;
539
+ }
540
+
541
+ function selected() {
542
+ const list = filteredIssues();
543
+ return list[state.index] || null;
544
+ }
545
+
546
+ function availableRows() {
547
+ try {
548
+ if (typeof rowsAvailable === 'function') {
549
+ const value = Math.floor(rowsAvailable());
550
+ if (Number.isFinite(value) && value > 0) return value;
551
+ }
552
+ } catch {
553
+ // A hostile rows source never breaks the view.
554
+ }
555
+ return 24;
556
+ }
557
+
558
+ function repaint() {
559
+ if (!active) return;
560
+ try {
561
+ if (typeof requestRender === 'function') requestRender();
562
+ } catch {
563
+ // A repaint failure never breaks navigation.
564
+ }
565
+ }
566
+
567
+ // `done` ends the overlay; nothing repaints after it, so an in-flight
568
+ // fetch that lands later cannot paint into a closed view — and a scheduled
569
+ // remote read is dropped instead of firing into a dead component.
570
+ function close() {
571
+ active = false;
572
+ cancelScheduledQuery();
573
+ if (typeof done === 'function') done(null);
574
+ }
575
+
576
+ // The state `gh` is asked for: always open (S10a). Exactly one state, never
577
+ // a combined all.
578
+ const PAGE_STATE = 'open';
579
+
580
+ // The state of one row, with `gh`'s absent state read as open.
581
+ function rowState(row) {
582
+ return String((row && row.state) || 'open');
583
+ }
584
+
585
+ function normalizePagesResult(result) {
586
+ if (!result || typeof result !== 'object') return { ok: false, error: 'issues could not be loaded' };
587
+ if (result.ok === false) {
588
+ return { ok: false, error: String(result.error || result.loadError || 'issues could not be loaded') };
589
+ }
590
+ const partial = typeof result.loadError === 'string' && result.loadError !== '' ? result.loadError : null;
591
+ if (Array.isArray(result.pages)) return { ok: true, pages: result.pages, loadError: partial };
592
+ if (Array.isArray(result.issues)) {
593
+ return { ok: true, issues: result.issues, total: result.total, truncated: result.truncated, loadError: partial };
594
+ }
595
+ return { ok: false, error: 'issues could not be loaded: unexpected result' };
596
+ }
597
+
598
+ function applyLoadedPages(settled) {
599
+ if (Array.isArray(settled.issues)) {
600
+ fallbackIssues = settled.issues.slice();
601
+ const current = pages[state.page];
602
+ if (current) {
603
+ current.issues = settled.issues.slice();
604
+ if (settled.total != null && Number.isFinite(Number(settled.total))) current.total = Number(settled.total);
605
+ if (settled.truncated != null) current.truncated = settled.truncated === true;
606
+ }
607
+ }
608
+ if (Array.isArray(settled.pages)) pages = settled.pages.map(mapPage);
609
+ if (state.page >= pages.length) state.page = Math.max(0, pages.length - 1);
610
+ details.clear();
611
+ state.index = 0;
612
+ state.listStart = 0;
613
+ state.scroll = 0;
614
+ actionError = null;
615
+ ensureDetail(selected());
616
+ }
617
+
618
+ // ---- server pages (S5/D1/S3) --------------------------------------------
619
+
620
+ // The item budget of the left list: every body row the frame leaves. Wrapped
621
+ // titles can only reduce how many issues fit, so a request asks for at most
622
+ // this many rows and the next read continues after the last LOADED row.
623
+ function listBodyRows() {
624
+ return Math.max(1, availableRows() - 6);
625
+ }
626
+
627
+ // The search widget for a given cell budget. The pager and its position
628
+ // marker are gone (S3), so the query display owns the whole row. S11: the
629
+ // `›` prompt and the idle `Search` label paint the dim LABEL role, while a
630
+ // live query is the VALUE role — the box says what is typed into it without
631
+ // ever painting its own label like content.
632
+ function searchWidget(room) {
633
+ if (room <= 0) return '';
634
+ const prefix = '› ';
635
+ if (state.searching) {
636
+ const tail = tailOf(helpers, state.query, Math.max(0, room - measure(helpers, `${prefix}█`)));
637
+ return `${palette.label(theme, prefix)}${palette.value(theme, `${tail}█`)}`;
638
+ }
639
+ if (state.query === '') return `${palette.label(theme, prefix)}${palette.label(theme, 'Search')}`;
640
+ const shown = tailOf(helpers, state.query, Math.max(0, room - measure(helpers, prefix)));
641
+ return `${palette.label(theme, prefix)}${palette.value(theme, shown)}`;
642
+ }
643
+
644
+ function cursorOf(row) {
645
+ return row && row.cursor != null && String(row.cursor) !== '' ? String(row.cursor) : null;
646
+ }
647
+
648
+ // One bucket's continuous-read state (S3):
649
+ // - `loaded` — its rows were read for `query`, so returning to the tab paints
650
+ // them instead of refetching;
651
+ // - `hasMore` — the provider says the bucket continues past the loaded rows;
652
+ // - `start`/`index` — the scroll window and the focused row of THIS bucket, so
653
+ // a tab switch restores the reader's place instead of jumping to row one;
654
+ // - `seq` — the generation the newest answer belongs to, which is how a slow
655
+ // answer is dropped instead of painting over newer rows;
656
+ // - `rowsQuery` — the query the rows CURRENTLY HELD answer (null when the
657
+ // bucket holds no rows). A failed read for another query must drop them:
658
+ // another query's rows are never this query's result (S9).
659
+ function bucketState(page) {
660
+ if (!page.rt) {
661
+ page.rt = {
662
+ start: 0,
663
+ index: 0,
664
+ loading: false,
665
+ loaded: false,
666
+ seq: 0,
667
+ error: null,
668
+ hasMore: true,
669
+ query: null,
670
+ rowsQuery: null,
671
+ };
672
+ }
673
+ return page.rt;
674
+ }
675
+
676
+ function normalizePageResult(result) {
677
+ if (!result || typeof result !== 'object') return { ok: false, error: 'issues could not be loaded' };
678
+ if (result.ok === false) {
679
+ return { ok: false, error: String(result.error || result.loadError || 'issues could not be loaded') };
680
+ }
681
+ if (!Array.isArray(result.issues)) return { ok: false, error: 'issues could not be loaded: unexpected result' };
682
+ const total = Number.isFinite(Number(result.total)) ? Number(result.total) : null;
683
+ return { ok: true, issues: result.issues.slice(), total, hasNextPage: result.hasNextPage === true };
684
+ }
685
+
686
+ // S3: ONE list, read continuously. A HEAD read (the default) replaces the
687
+ // bucket's rows with the first page of the current query and returns the
688
+ // reader to its top; an APPEND read continues after the last LOADED row and
689
+ // queues the new page below what is already painted, leaving the selection
690
+ // and the window where they are. Every read carries `query`, so a typed
691
+ // search still reads the COMPLETE bucket (S9), and only the newest answer for
692
+ // a bucket may paint (D1).
693
+ function requestBucketPage(bucketIndex, requestOptions = {}) {
694
+ const bucket = pages[bucketIndex];
695
+ if (!bucket || !pageLoaderFn) return null;
696
+ const keepSelection = Boolean(requestOptions) && requestOptions.keepSelection === true;
697
+ const append = Boolean(requestOptions) && requestOptions.append === true;
698
+ const query = String(state.query || '');
699
+ const rt = bucketState(bucket);
700
+ const loaded = Array.isArray(bucket.issues) ? bucket.issues : [];
701
+ const cursor = append ? cursorOf(loaded[loaded.length - 1]) : null;
702
+ if (append) {
703
+ // A read that was never answered, an answer still in flight, a complete
704
+ // bucket or a last row with no cursor has nothing honest to continue from.
705
+ if (!rt.loaded || rt.loading || !rt.hasMore || cursor === null) return null;
706
+ }
707
+ const seq = ++pageSeq;
708
+ rt.seq = seq;
709
+ rt.loading = true;
710
+ rt.error = null;
711
+ rt.query = query;
712
+ if (!append) rt.loaded = false;
713
+ if (bucketIndex === state.page) state.loading = true;
714
+ repaint();
715
+ const request = {
716
+ bucketId: bucket.id,
717
+ cursor,
718
+ limit: listBodyRows(),
719
+ query,
720
+ };
721
+ let pending = null;
722
+ try {
723
+ pending = pageLoaderFn(request);
724
+ } catch (err) {
725
+ pending = { ok: false, error: (err && err.message) || 'issues could not be loaded' };
726
+ }
727
+ const settle = (result) => {
728
+ if (!active || rt.seq !== seq) return;
729
+ rt.loading = false;
730
+ const normalized = normalizePageResult(result);
731
+ if (!normalized.ok) {
732
+ rt.error = normalized.error;
733
+ // S9: rows answer exactly ONE query. A failure for a different one (a
734
+ // typed search) drops them AND the count with them, so the previous
735
+ // query's rows can never be read as this query's result — the view
736
+ // paints the failure instead of stale data. A failed append or refresh
737
+ // of the SAME query keeps the rows already shown: they still answer
738
+ // that query, and losing them would blank a list the reader is on.
739
+ const answersQuery = rt.rowsQuery !== null && rt.rowsQuery === query;
740
+ if (!answersQuery) {
741
+ bucket.issues = null;
742
+ bucket.total = null;
743
+ bucket.truncated = false;
744
+ rt.rowsQuery = null;
745
+ rt.hasMore = false;
746
+ rt.loaded = false;
747
+ }
748
+ if (bucketIndex === state.page) {
749
+ state.loading = false;
750
+ state.loadError = normalized.error;
751
+ if (!answersQuery) {
752
+ state.index = 0;
753
+ state.listStart = 0;
754
+ state.scroll = 0;
755
+ }
756
+ }
757
+ repaint();
758
+ return;
759
+ }
760
+ bucket.issues = append ? [...loaded, ...normalized.issues] : normalized.issues;
761
+ bucket.total = normalized.total;
762
+ bucket.truncated = normalized.hasNextPage;
763
+ rt.hasMore = normalized.hasNextPage;
764
+ rt.loaded = true;
765
+ rt.rowsQuery = query;
766
+ rt.error = null;
767
+ if (bucketIndex === state.page) {
768
+ state.loading = false;
769
+ state.loadError = null;
770
+ actionError = null;
771
+ if (keepSelection) {
772
+ // S10: an explicit refresh re-reads the list the reader is on, so the
773
+ // focused row (and the preview beside it) survives.
774
+ clampSelection();
775
+ } else if (!append) {
776
+ // A new query or a first read starts the reader at the head.
777
+ state.index = 0;
778
+ state.listStart = 0;
779
+ state.scroll = 0;
780
+ }
781
+ // An appended page lands BELOW the reader, so the selection, the window
782
+ // and the preview stay exactly where they were.
783
+ if (!append || keepSelection) {
784
+ // The detail is read again even when the answer repeats the same rows,
785
+ // because comments live only in the detail.
786
+ ensureDetail(selected(), keepSelection ? { refresh: true } : undefined);
787
+ }
788
+ }
789
+ repaint();
790
+ };
791
+ Promise.resolve(pending).then(settle).catch((err) => {
792
+ settle({ ok: false, error: (err && err.message) || 'issues could not be loaded' });
793
+ });
794
+ return request;
795
+ }
796
+
797
+ // S3: the continuous read. Reaching the bottom of the rows loaded so far asks
798
+ // for the page after them; a bucket that is already whole, still loading, or
799
+ // never answered asks for nothing.
800
+ function loadMoreRows() {
801
+ if (!serverMode) return null;
802
+ return requestBucketPage(state.page, { append: true });
803
+ }
804
+
805
+ // S9: a successful write changes the repository, so every bucket's loaded
806
+ // page is stale. The rows stay painted (no blank frame, no lost selection),
807
+ // but the next visit to a bucket reads its page again instead of reusing the
808
+ // pre-write list.
809
+ function invalidateLoadedPages() {
810
+ if (!serverMode) return;
811
+ pages.forEach((page) => {
812
+ bucketState(page).loaded = false;
813
+ });
814
+ }
815
+
816
+ // S3: the left row is the title alone — the number lives in the preview — so
817
+ // the wrapped title owns the whole wider pane.
818
+ function listTitle(row) {
819
+ return String((row && row.title) || '');
820
+ }
821
+
822
+ function listRowCost(row, width) {
823
+ return layout.rowCost(helpers, listTitle(row), width, { first: '▸ ' });
824
+ }
825
+
826
+ // One page request through the given seam. The loader is called with
827
+ // `{state}`; its stale answer is dropped, not rendered.
828
+ function requestPages(source) {
829
+ if (typeof source !== 'function') return;
830
+ const seq = ++pagesSeq;
831
+ state.loading = true;
832
+ repaint();
833
+ let pending = null;
834
+ try {
835
+ pending = source({ state: PAGE_STATE });
836
+ } catch (err) {
837
+ pending = { ok: false, error: (err && err.message) || 'issues could not be loaded' };
838
+ }
839
+ const settle = (result) => {
840
+ if (seq !== pagesSeq || !active) return;
841
+ state.loading = false;
842
+ const normalized = normalizePagesResult(result);
843
+ if (!normalized.ok) {
844
+ state.loadError = normalized.error;
845
+ repaint();
846
+ return;
847
+ }
848
+ state.loadError = normalized.loadError;
849
+ applyLoadedPages(normalized);
850
+ repaint();
851
+ };
852
+ Promise.resolve(pending).then(settle).catch((err) => {
853
+ settle({ ok: false, error: (err && err.message) || 'issues could not be loaded' });
854
+ });
855
+ }
856
+
857
+ // Repo labels load on the label editor's FIRST use (S19): opening the
858
+ // view never waits on them. A failure stays retryable on the next open.
859
+ function requestLabels() {
860
+ if (typeof loadLabels !== 'function' || labelsLoaded || labelsLoading) return;
861
+ labelsLoading = true;
862
+ labelsError = null;
863
+ repaint();
864
+ let pending = null;
865
+ try {
866
+ pending = loadLabels();
867
+ } catch (err) {
868
+ pending = { ok: false, error: (err && err.message) || 'labels unreadable' };
869
+ }
870
+ const settle = (result) => {
871
+ labelsLoading = false;
872
+ if (result && result.ok === true && Array.isArray(result.labels)) {
873
+ repoLabels = result.labels.map((label) => ({ name: labelName(label) })).filter((label) => label.name);
874
+ labelsLoaded = true;
875
+ labelsError = null;
876
+ } else {
877
+ labelsError = (result && (result.error || result.loadError)) || 'labels unreadable';
878
+ }
879
+ repaint();
880
+ };
881
+ Promise.resolve(pending).then(settle).catch((err) => {
882
+ settle({ ok: false, error: (err && err.message) || 'labels unreadable' });
883
+ });
884
+ }
885
+
886
+ // S10a: a tab names the OPEN issues it lists. A page's own reported total
887
+ // describes the list exactly while every row it carries is open — a closed
888
+ // row means that total counts issues this view never shows, so the count
889
+ // falls back to the open rows actually listed.
890
+ function tabCount(page) {
891
+ const rows = Array.isArray(page.issues) ? page.issues : null;
892
+ const open = rows ? rows.filter((row) => rowState(row) === 'open') : null;
893
+ const honorsState = !rows || open.length === rows.length;
894
+ if (page.total != null && honorsState) return `${page.total}${page.truncated ? '+' : ''}`;
895
+ // A bucket nobody has read yet is UNKNOWN, never an invented zero: the
896
+ // reported `All (0)` described a repository that does have open issues.
897
+ if (rows === null && page.total == null) return '–';
898
+ const count = open ? open.length : fallbackIssues.filter((row) => rowState(row) === 'open').length;
899
+ return `${count}${page.truncated ? '+' : ''}`;
900
+ }
901
+
902
+ function titleText() {
903
+ // S13: the surface alone. The active bucket is named by its own tab right
904
+ // below, so the header never repeats it — and it names no checkout either.
905
+ return '◈ Issues';
906
+ }
907
+
908
+ function ensureDetail(row, detailOptions = {}) {
909
+ const refresh = Boolean(detailOptions) && detailOptions.refresh === true;
910
+ if (!row || !Number.isFinite(numOf(row))) return null;
911
+ const number = numOf(row);
912
+ let entry = details.get(number);
913
+ if (entry && !refresh) return entry;
914
+ const previous = entry && entry.issue ? entry.issue : row;
915
+ entry = { status: 'loading', issue: previous, comments: entry ? entry.comments : [], lines: [], error: null };
916
+ details.set(number, entry);
917
+ if (typeof loadIssue !== 'function') {
918
+ entry.status = 'ready';
919
+ entry.issue = row;
920
+ return entry;
921
+ }
922
+ let pending = null;
923
+ try {
924
+ pending = refresh ? loadIssue(number, { refresh: true }) : loadIssue(number);
925
+ } catch (err) {
926
+ entry.status = 'error';
927
+ entry.error = (err && err.message) || 'issue unreadable';
928
+ return entry;
929
+ }
930
+ const settle = (result) => {
931
+ const normalized = normalizeDetail(result);
932
+ if (normalized.ok) {
933
+ entry.status = 'ready';
934
+ entry.issue = normalized.issue;
935
+ entry.comments = normalized.comments;
936
+ } else {
937
+ entry.status = 'error';
938
+ entry.error = normalized.error;
939
+ }
940
+ repaint();
941
+ };
942
+ if (pending && typeof pending.then === 'function') {
943
+ pending.then(settle).catch((err) => {
944
+ entry.status = 'error';
945
+ entry.error = (err && err.message) || 'issue unreadable';
946
+ repaint();
947
+ });
948
+ } else {
949
+ settle(pending);
950
+ }
951
+ return entry;
952
+ }
953
+
954
+ // S16: a save that GitHub accepted invalidates the cached detail and reads
955
+ // it again, so the preview shows the saved truth instead of the pre-save
956
+ // copy. A failure never reaches here: the cache is left untouched.
957
+ function refetchDetail(number) {
958
+ const row = filteredIssues().find((candidate) => numOf(candidate) === number)
959
+ || (numOf(selected()) === number ? selected() : null);
960
+ if (!row) {
961
+ details.delete(number);
962
+ repaint();
963
+ return;
964
+ }
965
+ ensureDetail(row, { refresh: true });
966
+ repaint();
967
+ }
968
+
969
+ // `r` re-reads the selected issue so the repository permission carried by its
970
+ // detail is never a stale capability from an earlier moment of the session.
971
+ function refreshSelectedDetail() {
972
+ const row = selected();
973
+ if (!row || typeof loadIssue !== 'function') return;
974
+ refetchDetail(numOf(row));
975
+ }
976
+
977
+ // S12: the preview IS `lib/issue-preview.js` — assignees, projects, linked
978
+ // PRs, the body and every comment render through the migrated formatter, with
979
+ // no field dropped. Only the status lines for "no selection", a failed read
980
+ // and an in-flight read stay here, because those describe THIS view's state.
981
+ //
982
+ // The formatter's free text (the body and each comment body) is handed to the
983
+ // ODD document renderer through the formatter's own `wrap` seam, so markdown
984
+ // styling costs the preview nothing else: the metadata rows keep the
985
+ // formatter's wrap, its width budget and its content, and only the body and
986
+ // the comments can ever be read as markdown.
987
+ function markdownRows(source, width) {
988
+ const key = `${width}\u0000${source}`;
989
+ const cached = markdownMemo.get(key);
990
+ if (cached) {
991
+ // Re-insert, so the Map's own order is the eviction order.
992
+ markdownMemo.delete(key);
993
+ markdownMemo.set(key, cached);
994
+ return cached;
995
+ }
996
+ const rows = renderOddMarkdown(source, {
997
+ width,
998
+ // An issue body is github-flavored markdown: one newline inside a
999
+ // paragraph is a space, so a hard-wrapped body reads as one paragraph
1000
+ // instead of one line plus separator per source line.
1001
+ flowParagraphs: true,
1002
+ paint: (role, text) => paint(theme, role, text),
1003
+ emphasis: (text) => emphasize(theme, text),
1004
+ measure: (text) => measure(helpers, text),
1005
+ });
1006
+ markdownMemo.set(key, rows);
1007
+ while (markdownMemo.size > MARKDOWN_MEMO_ENTRIES) {
1008
+ const oldest = markdownMemo.keys().next();
1009
+ if (oldest.done) break;
1010
+ markdownMemo.delete(oldest.value);
1011
+ }
1012
+ return rows;
1013
+ }
1014
+
1015
+ function detailWrap(sources, width) {
1016
+ return (text) => {
1017
+ const source = String(text ?? '');
1018
+ if (!sources.has(source) || source.trim().length === 0) return defaultWrapCache.wrap(source, width);
1019
+ return markdownRows(source, width);
1020
+ };
1021
+ }
1022
+
1023
+ function detailLines(row, width) {
1024
+ const entry = ensureDetail(row);
1025
+ if (!entry) return [paint(theme, 'muted', '(no issue selected)')];
1026
+ if (entry.status === 'error') return [paint(theme, 'error', `unreadable: ${entry.error}`)];
1027
+ if (entry.status !== 'ready') return [paint(theme, 'muted', 'loading…')];
1028
+ const issue = entry.issue || row;
1029
+ const comments = Array.isArray(entry.comments) ? entry.comments : [];
1030
+ return issueDetailLines(issue, comments, {
1031
+ width,
1032
+ paint: (role, text) => paint(theme, role, text),
1033
+ emphasis: (text) => emphasize(theme, text),
1034
+ wrap: detailWrap(markdownBodySources(issue, comments), width),
1035
+ });
1036
+ }
1037
+
1038
+ function clampScroll(total) {
1039
+ const max = Math.max(0, total - Math.max(1, bodyRows));
1040
+ if (state.scroll > max) state.scroll = max;
1041
+ if (state.scroll < 0) state.scroll = 0;
1042
+ }
1043
+
1044
+ // S16: permissions come from the issue detail once it is established. While
1045
+ // it loads, and whenever no detail seam exists to confirm them, nothing is
1046
+ // confirmed. This lookup never starts a fetch, so painting the footer
1047
+ // cannot change what is in flight.
1048
+ function permissionsFor(row) {
1049
+ if (!row) return NO_PERMISSIONS;
1050
+ const number = numOf(row);
1051
+ const entry = Number.isFinite(number) ? details.get(number) : null;
1052
+ if (entry && entry.status === 'ready') return issuePermissions(entry.issue || row);
1053
+ if (typeof loadIssue !== 'function') return issuePermissions(row);
1054
+ return NO_PERMISSIONS;
1055
+ }
1056
+
1057
+ function render(width) {
1058
+ const w = typeof width === 'number' && Number.isFinite(width) ? Math.floor(width) : 0;
1059
+ if (w <= 0) return [];
1060
+ const height = availableRows();
1061
+ const title = titleText();
1062
+ if (w < MIN_WIDTH || height < 4) {
1063
+ return [pad(helpers, fit(helpers, `${title} — enlarge the terminal`, w), w)];
1064
+ }
1065
+ if (state.page >= pages.length) state.page = 0;
1066
+ clampSelection();
1067
+ // CARD_PADDING covers the frame's own `│ ` and ` │` cells, so this inner
1068
+ // width fills the terminal exactly: the right border belongs in the last
1069
+ // cell (S27 — no trailing slack, corners and edges stay aligned).
1070
+ const inner = Math.max(1, w - CARD_PADDING);
1071
+ // S3: the issues reader takes the SMALLER 30/70 share from view-layout, so
1072
+ // the reading pane gets the room and no 34/36-column cap survives.
1073
+ const panes = layout.paneWidths(inner, { listShare: layout.READER_LIST_SHARE });
1074
+ const listWidth = panes.left;
1075
+ const rightWidth = panes.right;
1076
+ listWidthCache = listWidth;
1077
+ bodyRows = Math.max(1, height - 6);
1078
+ const border = (text) => paint(theme, 'border', text);
1079
+ const divider = border('│');
1080
+ // S13: the legend names the interaction the keyboard is in — the search
1081
+ // keys while the box is open, the reading keys otherwise — on this ONE
1082
+ // row, and no hint is repeated on the row that already owns it. S16: an
1083
+ // editor whose capability is not confirmed true has no hint at all.
1084
+ const permissions = permissionsFor(selected());
1085
+ // S14: the shared scroll binding answers several hints for the ONE document
1086
+ // pane — its page pair, the half-page pair, the printable fallback. The
1087
+ // legend spends a single token on the target instead of listing every
1088
+ // alias; the keys themselves resolve through the same helper, so a hint
1089
+ // leaving the row never drops a binding.
1090
+ const documentScroll = palette.scrollHint(scrollKeys.previewScrollKeys(), 'scroll');
1091
+ const footerKeys = state.searching
1092
+ ? SEARCH_KEYS
1093
+ : [FOOTER_KEYS[0], documentScroll, ...FOOTER_KEYS.slice(1)]
1094
+ .filter((entry) => entry && (!entry.requires || permissions[entry.requires] === true));
1095
+ const footerCell = () => `${border('│')} ${pad(helpers, palette.legendRow(theme, footerKeys, inner, (text) => measure(helpers, text)), inner)} ${border('│')}`;
1096
+ const lines = [];
1097
+ // S11/S13: the frame title takes `customMessageLabel` (the Pi Agents view's own
1098
+ // title role) through
1099
+ // the shared palette, so the header reads as a title while the footer keys
1100
+ // keep the primary accent a button owns.
1101
+ const topLeft = `${border('╭─')} ${palette.title(theme, title)} `;
1102
+ // S27: no top-right `esc` text (the /changes frame has none) — the corner
1103
+ // and the right border stay in the terminal's last cell.
1104
+ const topRight = border('─╮');
1105
+ lines.push(`${topLeft}${border('─'.repeat(Math.max(0, w - measure(helpers, topLeft) - measure(helpers, topRight))))}${topRight}`);
1106
+ // S26/S13: the search/filter widget owns the row directly below the title
1107
+ // and carries the query only — it is the single place `T` is named, and
1108
+ // the keys that leave the box are named once, by the footer. S3: the pager
1109
+ // and its position marker are gone, so the query display gets the row. The
1110
+ // widget paints its own label/value split (S11).
1111
+ const searchLine = () => `${border('│')} ${pad(helpers, searchWidget(inner), inner)} ${border('│')}`;
1112
+
1113
+ // The gh-unavailable and load-error states replace the panes, never with fake rows.
1114
+ const loadError = state.loadError || (options.loadError ? String(options.loadError) : null);
1115
+ if (options.ghAvailable === false) {
1116
+ lines.push(searchLine());
1117
+ lines.push(`${border('│')} ${pad(helpers, paint(theme, 'error', 'gh unavailable: install gh and authenticate to use /issues'), inner)} ${border('│')}`);
1118
+ while (lines.length < height - 2) lines.push(`${border('│')} ${pad(helpers, '', inner)} ${border('│')}`);
1119
+ lines.push(footerCell());
1120
+ lines.push(border(`╰${'─'.repeat(Math.max(0, w - 2))}╯`));
1121
+ return lines.map((line) => pad(helpers, line, w));
1122
+ }
1123
+ if (loadError && !state.loading && filteredIssues().length === 0) {
1124
+ lines.push(searchLine());
1125
+ lines.push(`${border('│')} ${pad(helpers, paint(theme, 'error', `failed to load issues: ${loadError}`), inner)} ${border('│')}`);
1126
+ while (lines.length < height - 2) lines.push(`${border('│')} ${pad(helpers, '', inner)} ${border('│')}`);
1127
+ lines.push(footerCell());
1128
+ lines.push(border(`╰${'─'.repeat(Math.max(0, w - 2))}╯`));
1129
+ return lines.map((line) => pad(helpers, line, w));
1130
+ }
1131
+ // First paint with the loader: name the wait instead of claiming 0 issues.
1132
+ const activeBucket = pages[state.page] || null;
1133
+ const bucketLoaded = Boolean(activeBucket && Array.isArray(activeBucket.issues));
1134
+ if (state.loading && (pages.length === 0 || (serverMode && !bucketLoaded))) {
1135
+ lines.push(searchLine());
1136
+ lines.push(`${border('│')} ${pad(helpers, paint(theme, 'muted', 'loading issues…'), inner)} ${border('│')}`);
1137
+ while (lines.length < height - 2) lines.push(`${border('│')} ${pad(helpers, '', inner)} ${border('│')}`);
1138
+ lines.push(footerCell());
1139
+ lines.push(border(`╰${'─'.repeat(Math.max(0, w - 2))}╯`));
1140
+ return lines.map((line) => pad(helpers, line, w));
1141
+ }
1142
+
1143
+ lines.push(searchLine());
1144
+ // S11/S13: the tabs keep one palette too — the active tab's LABEL is a
1145
+ // TITLE (`customMessageLabel`, with its `▸` mark, so focus
1146
+ // survives a monochrome theme) while every COUNT is muted, active or not,
1147
+ // and an unvisited tab's label is muted.
1148
+ const tabs = pages.map((page, at) => {
1149
+ const active = at === state.page;
1150
+ const name = `${active ? '▸ ' : ''}${page.label}`;
1151
+ const tabTotal = ` (${tabCount(page)})`;
1152
+ if (!active) return palette.count(theme, `${name}${tabTotal}`);
1153
+ return `${palette.title(theme, name)}${palette.count(theme, tabTotal)}`;
1154
+ }).join(paint(theme, 'border', ' │ '));
1155
+ lines.push(`${border('│')} ${pad(helpers, tabs, inner)} ${border('│')}`);
1156
+
1157
+ // S26/S13: failures and notices share this visible row, so typing a query
1158
+ // never hides one — and it carries no state indicator and no repeated
1159
+ // count (S10a/S13). S10: a write in flight owns the row until it settles,
1160
+ // so the reader never has to guess whether the save landed.
1161
+ const savingNote = state.saving ? paint(theme, 'accent', saveLabel(state.saving)) : '';
1162
+ const errorNote = savingNote || (actionError ? paint(theme, 'error', actionError)
1163
+ : (loadError ? paint(theme, 'error', String(loadError)) : ''));
1164
+ // S9: a scheduled remote search names itself instead of looking frozen.
1165
+ const note = errorNote || (notice ? paint(theme, 'accent', notice) : '')
1166
+ || (state.searchPending ? paint(theme, 'muted', 'searching…') : '')
1167
+ || (state.loading ? paint(theme, 'muted', 'loading…') : '');
1168
+ lines.push(`${border('│')} ${pad(helpers, note, inner)} ${border('│')}`);
1169
+
1170
+ const list = filteredIssues();
1171
+ const focused = selected();
1172
+ if (focused) ensureDetail(focused);
1173
+ // S3: the list is ONE continuous window — capacity follows the wrapped row
1174
+ // heights against the real body budget, and the window follows the focused
1175
+ // row instead of snapping to a page boundary. A bucket keeps its own
1176
+ // window and focus so a tab switch restores the reader's place.
1177
+ const bounds = layout.scrollWindow(
1178
+ list.map((row) => listRowCost(row, listWidth)),
1179
+ { budget: Math.max(1, bodyRows), index: state.index, start: state.listStart },
1180
+ );
1181
+ state.listStart = bounds.start;
1182
+ if (serverMode && activeBucket) {
1183
+ const rt = bucketState(activeBucket);
1184
+ rt.start = bounds.start;
1185
+ rt.index = state.index;
1186
+ }
1187
+ const leftLines = [];
1188
+ for (let at = bounds.start; at < bounds.end; at += 1) {
1189
+ const isFocused = at === state.index;
1190
+ const rows = layout.wrapLabel(helpers, listTitle(list[at]), listWidth, { first: isFocused ? '▸ ' : ' ' });
1191
+ for (const row of rows) leftLines.push(highlight(theme, pad(helpers, row, listWidth), isFocused));
1192
+ }
1193
+
1194
+ let rightSource;
1195
+ if (state.mode === 'labels') {
1196
+ rightSource = labelEditorLines(rightWidth);
1197
+ } else if (state.mode === 'assignees') {
1198
+ rightSource = assigneeEditorLines(rightWidth);
1199
+ } else if (state.mode === 'comment') {
1200
+ rightSource = commentLines(rightWidth);
1201
+ } else if (state.mode === 'confirm') {
1202
+ rightSource = confirmLines(rightWidth);
1203
+ } else if (list.length === 0) {
1204
+ // A search the reader is still waiting on has no result to report yet, so
1205
+ // the pane never claims an empty match for it (S9).
1206
+ if (state.searchPending || state.loading) {
1207
+ rightSource = [paint(theme, 'muted', 'loading issues…')];
1208
+ } else if (state.query) {
1209
+ rightSource = [paint(theme, 'muted', 'no issues match this filter')];
1210
+ } else {
1211
+ rightSource = [paint(theme, 'muted', 'no issues'), paint(theme, 'dim', 'nothing to show here')];
1212
+ }
1213
+ } else {
1214
+ rightSource = detailLines(focused, rightWidth);
1215
+ }
1216
+ clampScroll(rightSource.length);
1217
+ const visible = rightSource.slice(state.scroll, state.scroll + bodyRows);
1218
+ // Empty left rows (a window shorter than the body) still paint blank cells.
1219
+ for (let row = 0; row < bodyRows; row += 1) {
1220
+ const left = pad(helpers, leftLines[row] ?? '', listWidth);
1221
+ const right = pad(helpers, visible[row] ?? '', rightWidth);
1222
+ lines.push(`${border('│')} ${left} ${divider} ${right} ${border('│')}`);
1223
+ }
1224
+ lines.push(footerCell());
1225
+ lines.push(border(`╰${'─'.repeat(Math.max(0, w - 2))}╯`));
1226
+ return lines.map((line) => pad(helpers, line, w));
1227
+ }
1228
+
1229
+ // The issue an editor starts from: the established detail when it is ready,
1230
+ // because the list row may not carry labels or assignees the detail has;
1231
+ // otherwise the row itself. Never starts a fetch of its own.
1232
+ function currentIssue(row) {
1233
+ if (!row) return null;
1234
+ const number = numOf(row);
1235
+ const entry = Number.isFinite(number) ? details.get(number) : null;
1236
+ if (entry && entry.status === 'ready' && entry.issue) return entry.issue;
1237
+ return row;
1238
+ }
1239
+
1240
+ function currentLabelState() {
1241
+ const issue = currentIssue(selected());
1242
+ const names = new Set((issue && Array.isArray(issue.labels) ? issue.labels : []).map(labelName).filter(Boolean));
1243
+ return names;
1244
+ }
1245
+
1246
+ // Label editor local state: toggles start from the row's labels; created
1247
+ // names are pending until ctrl+s applies them through editLabels.
1248
+ let labelDraft = null;
1249
+ function openLabelEditor() {
1250
+ const row = selected();
1251
+ if (!row) return;
1252
+ const permissions = permissionsFor(row);
1253
+ if (!permissions.canEditLabels) {
1254
+ // S16: the key is refused outright — no editor, no loader, no write —
1255
+ // and the refusal names what is missing instead of guessing.
1256
+ actionError = 'labels cannot be edited: this repository permission is missing';
1257
+ repaint();
1258
+ return;
1259
+ }
1260
+ labelDraft = {
1261
+ number: numOf(row),
1262
+ selected: new Set(currentLabelState()),
1263
+ created: [],
1264
+ original: new Set(currentLabelState()),
1265
+ // S16: creating a label is a WRITE capability, distinct from the triage
1266
+ // permission that only applies labels that already exist.
1267
+ createAllowed: permissions.canCreateLabels,
1268
+ };
1269
+ state.mode = 'labels';
1270
+ state.labelIndex = 0;
1271
+ state.labelCreating = false;
1272
+ state.createName = '';
1273
+ actionError = null;
1274
+ state.scroll = 0;
1275
+ requestLabels();
1276
+ repaint();
1277
+ }
1278
+
1279
+ function labelEditorLines(width) {
1280
+ // S16: without canCreateLabels the editor names no create keybinding and
1281
+ // lists no create row, so a triage-only viewer never reads about a
1282
+ // capability it does not have.
1283
+ const keys = ['space toggle'];
1284
+ if (labelDraft && labelDraft.createAllowed) keys.push('n new');
1285
+ keys.push('ctrl+s apply', 'esc back');
1286
+ const out = [emphasize(theme, `Labels (${keys.join(' · ')})`)];
1287
+ if (!labelDraft) return out;
1288
+ if (labelsLoading) {
1289
+ out.push(paint(theme, 'muted', 'loading labels…'));
1290
+ return out;
1291
+ }
1292
+ if (labelsError) out.push(paint(theme, 'error', `labels unreadable: ${labelsError}`));
1293
+ const names = repoLabels.map((l) => l.name);
1294
+ // S16: without canCreateLabels the list ends at the existing labels —
1295
+ // there is no create row to reach.
1296
+ const rows = labelDraft.createAllowed ? [...names, '+ create new…'] : names.slice();
1297
+ rows.forEach((name, at) => {
1298
+ const isCreate = at === rows.length - 1;
1299
+ const on = !isCreate && labelDraft.selected.has(name);
1300
+ const cursor = at === state.labelIndex ? '▸ ' : ' ';
1301
+ if (isCreate && state.labelCreating) {
1302
+ out.push(...wrapText(`${cursor}new: ${state.createName}█ (enter add · esc cancel)`, width));
1303
+ } else {
1304
+ out.push(...wrapText(`${cursor}[${on ? 'x' : ' '}] ${name}`, width));
1305
+ }
1306
+ });
1307
+ if (labelDraft.created.length > 0) out.push(...wrapText(`new labels: ${labelDraft.created.join(', ')}`, width));
1308
+ if (actionError) out.push(paint(theme, 'error', actionError));
1309
+ return out;
1310
+ }
1311
+
1312
+ function commentLines(width) {
1313
+ const out = [emphasize(theme, 'Leave a comment… (enter newline · ctrl+s post · esc discard)')];
1314
+ // The discard ask names what is at stake; the draft stays visible
1315
+ // below it until `y` throws it away.
1316
+ if (state.discardAsk) out.push(paint(theme, 'error', 'Discard this draft? (y discard · n/esc keep editing)'));
1317
+ // S11: the caret sits at the real insertion point — after the last typed
1318
+ // character, on the line typing would continue on — so the editor shows
1319
+ // where the text goes instead of a marker detached from it. The wrapped
1320
+ // rows ARE that line: `wrapText` already keeps the empty row a trailing
1321
+ // newline opens, so asking the raw text instead painted the caret one row
1322
+ // below the insertion point on the first frame after opening or `enter`.
1323
+ const lines = wrapText(state.commentText, width);
1324
+ const caret = paint(theme, 'accent', '█');
1325
+ const last = lines.at(-1) ?? '';
1326
+ if (measure(helpers, last) >= width) {
1327
+ lines.push(caret);
1328
+ } else {
1329
+ lines.splice(-1, 1, `${last}${caret}`);
1330
+ }
1331
+ out.push(...lines);
1332
+ if (actionError) out.push(paint(theme, 'error', actionError));
1333
+ return out;
1334
+ }
1335
+
1336
+ function confirmLines(width) {
1337
+ const row = selected();
1338
+ const out = [emphasize(theme, `Confirm close #${row ? row.number : ''} (enter confirm · esc cancel)`)];
1339
+ out.push(...wrapText('This will close the issue on GitHub.', width));
1340
+ if (actionError) out.push(paint(theme, 'error', actionError));
1341
+ return out;
1342
+ }
1343
+
1344
+ // Closing (and reopening) is the same TRIAGE-level repository capability the
1345
+ // editors need: the key resolves it BEFORE the confirmation opens, so an
1346
+ // unpermitted viewer sees no editor and never reaches the write.
1347
+ function openCloseConfirm() {
1348
+ const row = selected();
1349
+ if (!row) return;
1350
+ if (!permissionsFor(row).canCloseIssues) {
1351
+ actionError = 'issues cannot be closed: this repository permission is missing';
1352
+ repaint();
1353
+ return;
1354
+ }
1355
+ state.mode = 'confirm';
1356
+ state.confirm = 'closed';
1357
+ actionError = null;
1358
+ repaint();
1359
+ }
1360
+
1361
+ function moveSelection(step) {
1362
+ const total = filteredIssues().length;
1363
+ if (total === 0) return;
1364
+ // S3: the selection walks the whole loaded list; the painted window follows
1365
+ // it, so a row below the window is never fetched a second time to be seen.
1366
+ state.index = (state.index + step + total) % total;
1367
+ state.scroll = 0;
1368
+ actionError = null;
1369
+ ensureDetail(selected());
1370
+ // Walking down onto the last loaded row is the same "reached the bottom"
1371
+ // signal a scroll gives, so the list keeps going instead of stopping dead.
1372
+ if (step > 0 && state.index === total - 1) loadMoreRows();
1373
+ repaint();
1374
+ }
1375
+
1376
+ // D3: tab selects the bucket. S10: `←`/`→` are the SAME bucket switch — a
1377
+ // scroll on those keys made no sense in a tabbed reader — and the list keeps
1378
+ // its own scroll keys (`]`/`[`). S13: a bucket that already answered for the
1379
+ // current query paints again — its rows, its scroll window and its focused
1380
+ // row, no request and no loading flash — while a bucket that was never read
1381
+ // (or whose rows a write invalidated) is read once. `r` stays the explicit
1382
+ // refresh.
1383
+ function switchPage(step) {
1384
+ if (pages.length === 0) return;
1385
+ state.page = (state.page + step + pages.length) % pages.length;
1386
+ state.index = 0;
1387
+ state.listStart = 0;
1388
+ state.scroll = 0;
1389
+ state.searching = false;
1390
+ actionError = null;
1391
+ if (serverMode) {
1392
+ const rt = bucketState(pages[state.page]);
1393
+ state.loadError = null;
1394
+ if (rt.query === String(state.query || '') && (rt.loaded || rt.loading)) {
1395
+ state.loading = rt.loading;
1396
+ // The tab remembers where the reader left it (S3), so returning restores
1397
+ // the window instead of jumping back to the first row.
1398
+ state.listStart = Math.max(0, Math.floor(Number(rt.start) || 0));
1399
+ state.index = Math.max(0, Math.floor(Number(rt.index) || 0));
1400
+ clampSelection();
1401
+ ensureDetail(selected());
1402
+ repaint();
1403
+ return;
1404
+ }
1405
+ requestBucketPage(state.page, {});
1406
+ return;
1407
+ }
1408
+ ensureDetail(selected());
1409
+ repaint();
1410
+ }
1411
+
1412
+ // S3: `]`/`[` scroll the ACTIVE list a screenful (S10 moved the arrows to the
1413
+ // bucket switch). There is no page to move to and
1414
+ // no boundary to record: the window moves over the one list, the focused row
1415
+ // follows into the new screen when it scrolled out of view, and reaching the
1416
+ // bottom of the loaded rows is what asks the server seam for the next page.
1417
+ function scrollList(step) {
1418
+ const list = filteredIssues();
1419
+ if (list.length === 0) return;
1420
+ const costs = list.map((row) => listRowCost(row, listWidthCache));
1421
+ const budget = Math.max(1, bodyRows);
1422
+ const target = layout.shiftWindow(costs, { budget, start: state.listStart, step });
1423
+ if (target !== state.listStart) {
1424
+ // The window the reader asked for: keep it exactly where the shift put it
1425
+ // instead of letting the focused row pull it back.
1426
+ state.listStart = target;
1427
+ const win = layout.scrollWindow(costs, { budget, index: target, start: target });
1428
+ state.listStart = win.start;
1429
+ if (state.index < win.start || state.index >= win.end) {
1430
+ state.index = win.start;
1431
+ state.scroll = 0;
1432
+ ensureDetail(selected());
1433
+ }
1434
+ if (serverMode) {
1435
+ const bucket = pages[state.page];
1436
+ if (bucket) bucketState(bucket).start = state.listStart;
1437
+ }
1438
+ }
1439
+ // A forward scroll that could not move is a scroll past the rows loaded so
1440
+ // far — which is exactly the request the next page answers.
1441
+ if (step > 0) loadMoreRows();
1442
+ repaint();
1443
+ }
1444
+
1445
+ function settleAsync(promise, onOk, onFail) {
1446
+ Promise.resolve(promise).then((result) => {
1447
+ if (result && result.ok === true) onOk(result);
1448
+ else onFail((result && result.error) || 'action failed');
1449
+ }).catch((err) => {
1450
+ onFail((err && err.message) || 'action failed');
1451
+ });
1452
+ }
1453
+
1454
+ // S10: the one-line description of the write in flight.
1455
+ const SAVE_LABELS = {
1456
+ labels: 'saving labels…',
1457
+ assignees: 'saving assignees…',
1458
+ comment: 'posting comment…',
1459
+ close: 'closing issue…',
1460
+ };
1461
+
1462
+ function saveLabel(kind) {
1463
+ return SAVE_LABELS[kind] || 'saving…';
1464
+ }
1465
+
1466
+ // S10: a write owns the view from its key until its answer. The state is
1467
+ // recorded and painted BEFORE the injected action is invoked, so the very
1468
+ // first frame after the key already says what is in flight, and `handleInput`
1469
+ // ignores every key meanwhile — a second save, a navigation or typing can
1470
+ // never race it.
1471
+ function beginSave(kind) {
1472
+ state.saving = kind;
1473
+ actionError = null;
1474
+ notice = null;
1475
+ repaint();
1476
+ }
1477
+
1478
+ function settleSave(pending, onOk, onFail) {
1479
+ const finish = (result) => {
1480
+ if (!active) return;
1481
+ state.saving = null;
1482
+ if (result && result.ok === true) onOk(result);
1483
+ else onFail((result && result.error) || 'action failed');
1484
+ };
1485
+ Promise.resolve(pending).then(finish).catch((err) => {
1486
+ finish({ ok: false, error: (err && err.message) || 'action failed' });
1487
+ });
1488
+ }
1489
+
1490
+ function applyLabels() {
1491
+ if (!labelDraft || state.saving !== null) return;
1492
+ const edit = actions && typeof actions.editLabels === 'function' ? actions.editLabels : null;
1493
+ if (typeof edit !== 'function') {
1494
+ actionError = 'label editing is unavailable';
1495
+ repaint();
1496
+ return;
1497
+ }
1498
+ const add = [...labelDraft.selected].filter((name) => !labelDraft.original.has(name));
1499
+ const remove = [...labelDraft.original].filter((name) => !labelDraft.selected.has(name));
1500
+ const number = labelDraft.number;
1501
+ beginSave('labels');
1502
+ let result = null;
1503
+ try {
1504
+ result = edit(number, add, remove, labelDraft.created.slice());
1505
+ } catch (err) {
1506
+ result = { ok: false, error: (err && err.message) || 'action failed' };
1507
+ }
1508
+ settleSave(result, () => {
1509
+ const row = filteredIssues().find((candidate) => numOf(candidate) === number);
1510
+ // The list row keeps the saved labels for its own display; the DETAIL
1511
+ // cache is dropped, never patched, so the preview reads GitHub again.
1512
+ if (row) row.labels = [...labelDraft.selected].map((name) => ({ name }));
1513
+ labelDraft = null;
1514
+ state.mode = 'list';
1515
+ actionError = null;
1516
+ invalidateLoadedPages();
1517
+ refetchDetail(number);
1518
+ repaint();
1519
+ }, (error) => {
1520
+ actionError = error;
1521
+ repaint();
1522
+ });
1523
+ }
1524
+
1525
+ // S16: assignees are a toggle list too — the draft starts from the issue's
1526
+ // current assignees, so applying it sends only the add/remove delta.
1527
+ let assigneeDraft = null;
1528
+
1529
+ function currentAssigneeState() {
1530
+ const issue = currentIssue(selected());
1531
+ return new Set((issue && Array.isArray(issue.assignees) ? issue.assignees : []).map(assigneeName).filter(Boolean));
1532
+ }
1533
+
1534
+ function requestAssignees() {
1535
+ if (typeof loadAssignees !== 'function' || assigneesLoaded || assigneesLoading) return;
1536
+ assigneesLoading = true;
1537
+ assigneesError = null;
1538
+ repaint();
1539
+ let pending = null;
1540
+ try {
1541
+ pending = loadAssignees();
1542
+ } catch (err) {
1543
+ pending = { ok: false, error: (err && err.message) || 'assignees unreadable' };
1544
+ }
1545
+ const settle = (result) => {
1546
+ assigneesLoading = false;
1547
+ if (result && result.ok === true && Array.isArray(result.users)) {
1548
+ assigneeUsers = result.users.map(assigneeOption).filter(Boolean);
1549
+ assigneesLoaded = true;
1550
+ assigneesError = null;
1551
+ } else {
1552
+ assigneesError = (result && (result.error || result.loadError)) || 'assignees unreadable';
1553
+ }
1554
+ repaint();
1555
+ };
1556
+ Promise.resolve(pending).then(settle).catch((err) => {
1557
+ settle({ ok: false, error: (err && err.message) || 'assignees unreadable' });
1558
+ });
1559
+ }
1560
+
1561
+ function openAssigneeEditor() {
1562
+ const row = selected();
1563
+ if (!row) return;
1564
+ if (!permissionsFor(row).canEditAssignees) {
1565
+ actionError = 'assignees cannot be edited: this repository permission is missing';
1566
+ repaint();
1567
+ return;
1568
+ }
1569
+ assigneeDraft = { number: numOf(row), selected: currentAssigneeState(), original: currentAssigneeState() };
1570
+ state.mode = 'assignees';
1571
+ state.assigneeIndex = 0;
1572
+ actionError = null;
1573
+ state.scroll = 0;
1574
+ requestAssignees();
1575
+ repaint();
1576
+ }
1577
+
1578
+ // The editor rows: every assignable user, plus any current assignee the user
1579
+ // list does not carry, so an unexpected name can still be removed.
1580
+ function assigneeRows() {
1581
+ const logins = assigneeUsers.map((user) => user.login);
1582
+ for (const login of (assigneeDraft ? assigneeDraft.original : [])) {
1583
+ if (!logins.includes(login)) logins.push(login);
1584
+ }
1585
+ return logins;
1586
+ }
1587
+
1588
+ function assigneeEditorLines(width) {
1589
+ const out = [emphasize(theme, 'Assignees (space toggle · ctrl+s apply · esc back)')];
1590
+ if (!assigneeDraft) return out;
1591
+ if (assigneesLoading) {
1592
+ out.push(paint(theme, 'muted', 'loading assignees…'));
1593
+ return out;
1594
+ }
1595
+ if (assigneesError) out.push(paint(theme, 'error', `assignees unreadable: ${assigneesError}`));
1596
+ const logins = assigneeRows();
1597
+ logins.forEach((login, at) => {
1598
+ const on = assigneeDraft.selected.has(login);
1599
+ const user = assigneeUsers.find((candidate) => candidate.login === login);
1600
+ const cursor = at === state.assigneeIndex ? '▸ ' : ' ';
1601
+ const label = user && user.name && user.name !== login ? `${login} (${user.name})` : login;
1602
+ out.push(...wrapText(`${cursor}[${on ? 'x' : ' '}] ${label}`, width));
1603
+ });
1604
+ if (logins.length === 0) out.push(paint(theme, 'muted', 'no assignable users'));
1605
+ if (actionError) out.push(paint(theme, 'error', actionError));
1606
+ return out;
1607
+ }
1608
+
1609
+ function applyAssignees() {
1610
+ if (!assigneeDraft || state.saving !== null) return;
1611
+ const edit = actions && typeof actions.editAssignees === 'function' ? actions.editAssignees : null;
1612
+ if (typeof edit !== 'function') {
1613
+ actionError = 'assignee editing is unavailable';
1614
+ repaint();
1615
+ return;
1616
+ }
1617
+ const add = [...assigneeDraft.selected].filter((login) => !assigneeDraft.original.has(login));
1618
+ const remove = [...assigneeDraft.original].filter((login) => !assigneeDraft.selected.has(login));
1619
+ const number = assigneeDraft.number;
1620
+ beginSave('assignees');
1621
+ let result = null;
1622
+ try {
1623
+ result = edit(number, { add, remove });
1624
+ } catch (err) {
1625
+ result = { ok: false, error: (err && err.message) || 'action failed' };
1626
+ }
1627
+ settleSave(result, () => {
1628
+ const row = filteredIssues().find((candidate) => numOf(candidate) === number);
1629
+ if (row) row.assignees = [...assigneeDraft.selected].map((login) => ({ login }));
1630
+ assigneeDraft = null;
1631
+ state.mode = 'list';
1632
+ actionError = null;
1633
+ invalidateLoadedPages();
1634
+ refetchDetail(number);
1635
+ repaint();
1636
+ }, (error) => {
1637
+ // S16: a rejected save keeps the draft — and therefore every local
1638
+ // toggle — exactly as it was, and shows why the server refused.
1639
+ actionError = error;
1640
+ repaint();
1641
+ });
1642
+ }
1643
+
1644
+ function handleAssigneeInput(data) {
1645
+ // While the users are in flight only the exit works: toggling against an
1646
+ // unknown list would guess.
1647
+ if (assigneesLoading) {
1648
+ if (keyMatches(helpers, data, ['escape'])) {
1649
+ state.mode = 'list';
1650
+ assigneeDraft = null;
1651
+ actionError = null;
1652
+ repaint();
1653
+ }
1654
+ return true;
1655
+ }
1656
+ if (keyMatches(helpers, data, ['ctrl+s'])) {
1657
+ applyAssignees();
1658
+ return true;
1659
+ }
1660
+ if (keyMatches(helpers, data, ['escape'])) {
1661
+ state.mode = 'list';
1662
+ assigneeDraft = null;
1663
+ actionError = null;
1664
+ repaint();
1665
+ return true;
1666
+ }
1667
+ const logins = assigneeRows();
1668
+ if (logins.length === 0) return true;
1669
+ if (state.assigneeIndex >= logins.length) state.assigneeIndex = logins.length - 1;
1670
+ if (keyMatches(helpers, data, ['up', 'k'])) {
1671
+ state.assigneeIndex = (state.assigneeIndex - 1 + logins.length) % logins.length;
1672
+ repaint();
1673
+ return true;
1674
+ }
1675
+ if (keyMatches(helpers, data, ['down', 'j'])) {
1676
+ state.assigneeIndex = (state.assigneeIndex + 1) % logins.length;
1677
+ repaint();
1678
+ return true;
1679
+ }
1680
+ if (keyMatches(helpers, data, ['space', ' ', 'enter'])) {
1681
+ const login = logins[state.assigneeIndex];
1682
+ if (login) {
1683
+ if (assigneeDraft.selected.has(login)) assigneeDraft.selected.delete(login);
1684
+ else assigneeDraft.selected.add(login);
1685
+ }
1686
+ repaint();
1687
+ return true;
1688
+ }
1689
+ return true;
1690
+ }
1691
+
1692
+ function postComment() {
1693
+ const post = actions && typeof actions.postComment === 'function' ? actions.postComment : null;
1694
+ const row = selected();
1695
+ if (typeof post !== 'function' || !row) {
1696
+ actionError = 'commenting is unavailable';
1697
+ repaint();
1698
+ return;
1699
+ }
1700
+ if (state.saving !== null) return;
1701
+ const text = state.commentText;
1702
+ const number = numOf(row);
1703
+ beginSave('comment');
1704
+ let result = null;
1705
+ try {
1706
+ result = post(number, text);
1707
+ } catch (err) {
1708
+ result = { ok: false, error: (err && err.message) || 'action failed' };
1709
+ }
1710
+ settleSave(result, () => {
1711
+ state.mode = 'list';
1712
+ state.commentText = '';
1713
+ state.discardAsk = false;
1714
+ actionError = null;
1715
+ // S6: the posted comment is only visible once the detail is read again —
1716
+ // page rows carry no comments. A failed refresh paints its own error, so
1717
+ // the result is never presented as updated data.
1718
+ notice = 'comment posted';
1719
+ invalidateLoadedPages();
1720
+ refetchDetail(number);
1721
+ repaint();
1722
+ }, (error) => {
1723
+ actionError = error;
1724
+ repaint();
1725
+ });
1726
+ }
1727
+
1728
+ function confirmState() {
1729
+ const setState = actions && typeof actions.setState === 'function' ? actions.setState : null;
1730
+ const row = selected();
1731
+ if (typeof setState !== 'function' || !row || state.confirm !== 'closed') {
1732
+ actionError = 'state change is unavailable';
1733
+ repaint();
1734
+ return;
1735
+ }
1736
+ // The capability is resolved again at the write, so a permission that was
1737
+ // withdrawn while the confirmation was on screen never reaches GitHub.
1738
+ if (!permissionsFor(row).canCloseIssues) {
1739
+ state.mode = 'list';
1740
+ state.confirm = null;
1741
+ actionError = 'issues cannot be closed: this repository permission is missing';
1742
+ repaint();
1743
+ return;
1744
+ }
1745
+ if (state.saving !== null) return;
1746
+ const number = numOf(row);
1747
+ beginSave('close');
1748
+ let result = null;
1749
+ try {
1750
+ result = setState(number, 'closed');
1751
+ } catch (err) {
1752
+ result = { ok: false, error: (err && err.message) || 'action failed' };
1753
+ }
1754
+ settleSave(result, () => {
1755
+ // S10a: the list is open-only, so a closed issue leaves it at once. The
1756
+ // row keeps its new state where it is stored, and the open-only filter
1757
+ // and every count follow it.
1758
+ row.state = 'closed';
1759
+ const cached = details.get(number);
1760
+ if (cached && cached.issue) cached.issue = { ...cached.issue, state: 'closed' };
1761
+ state.mode = 'list';
1762
+ state.confirm = null;
1763
+ actionError = null;
1764
+ invalidateLoadedPages();
1765
+ clampSelection();
1766
+ repaint();
1767
+ }, (error) => {
1768
+ actionError = error;
1769
+ repaint();
1770
+ });
1771
+ }
1772
+
1773
+ // S9: the remote search read is scheduled, not issued. One live callback at
1774
+ // a time: scheduling a new query cancels the previous one, so a burst of
1775
+ // typing never accumulates timers, and the read that finally goes out asks
1776
+ // for page one of the query that survived.
1777
+ function scheduleQueryRead(query) {
1778
+ cancelScheduledQuery();
1779
+ state.searchPending = true;
1780
+ const entry = { query, handle: null, fired: false };
1781
+ scheduledQuery = entry;
1782
+ const run = () => {
1783
+ if (entry.fired || scheduledQuery !== entry) return;
1784
+ entry.fired = true;
1785
+ scheduledQuery = null;
1786
+ state.searchPending = false;
1787
+ if (!active || query !== String(state.query || '')) return;
1788
+ requestBucketPage(state.page, {});
1789
+ };
1790
+ let handle = null;
1791
+ try {
1792
+ handle = scheduleFn(run, QUERY_DEBOUNCE_MS);
1793
+ } catch {
1794
+ // A scheduler that cannot schedule degrades to the immediate read.
1795
+ run();
1796
+ return;
1797
+ }
1798
+ entry.handle = handle;
1799
+ repaint();
1800
+ }
1801
+
1802
+ function cancelScheduledQuery() {
1803
+ if (scheduledQuery === null) return false;
1804
+ const { handle } = scheduledQuery;
1805
+ scheduledQuery = null;
1806
+ state.searchPending = false;
1807
+ try {
1808
+ cancelFn(handle);
1809
+ } catch {
1810
+ // A hostile scheduler never breaks the view.
1811
+ }
1812
+ return true;
1813
+ }
1814
+
1815
+ // The scheduled read, issued now. Leaving the box, refreshing or paging under
1816
+ // a query nobody has read yet must not wait for a timer the reader can no
1817
+ // longer see, and must not fire it a second time afterwards: page one of the
1818
+ // current query is the only honest page, and the flushed call IS that read.
1819
+ function flushScheduledQuery() {
1820
+ if (scheduledQuery === null) return false;
1821
+ const { query } = scheduledQuery;
1822
+ cancelScheduledQuery();
1823
+ if (!active || !serverMode) return false;
1824
+ if (query !== String(state.query || '')) return false;
1825
+ requestBucketPage(state.page, {});
1826
+ return true;
1827
+ }
1828
+
1829
+ // S9: a query edit is a new dataset read on the server seam — the request
1830
+ // carries `query`, so it searches the COMPLETE bucket rather than the loaded
1831
+ // page. The query and the caret apply at once; the read is debounced. Static
1832
+ // and `loadPages` callers keep filtering the rows they already hold, at once.
1833
+ function setQuery(next) {
1834
+ state.query = next;
1835
+ state.index = 0;
1836
+ state.listStart = 0;
1837
+ state.scroll = 0;
1838
+ if (serverMode) {
1839
+ const bucket = pages[state.page];
1840
+ if (bucket) {
1841
+ const rt = bucketState(bucket);
1842
+ // The rows on screen belong to the previous query and any answer for it
1843
+ // is already stale: invalidate both NOW, so a slow earlier answer can
1844
+ // never paint while this query is still inside its debounce window.
1845
+ rt.loaded = false;
1846
+ rt.loading = false;
1847
+ rt.seq = ++pageSeq;
1848
+ state.loading = false;
1849
+ // A failure still on screen belongs to the previous query, so it is
1850
+ // never the answer to this one either.
1851
+ state.loadError = null;
1852
+ scheduleQueryRead(state.query);
1853
+ return;
1854
+ }
1855
+ }
1856
+ repaint();
1857
+ }
1858
+
1859
+ function handleSearchInput(data) {
1860
+ if (keyMatches(helpers, data, ['enter'])) {
1861
+ state.searching = false;
1862
+ state.index = 0;
1863
+ state.listStart = 0;
1864
+ state.scroll = 0;
1865
+ // S9: leaving the box applies the filter NOW instead of waiting for a
1866
+ // debounce the reader can no longer see.
1867
+ if (!flushScheduledQuery()) repaint();
1868
+ return true;
1869
+ }
1870
+ if (keyMatches(helpers, data, ['escape'])) {
1871
+ state.searching = false;
1872
+ state.index = 0;
1873
+ state.listStart = 0;
1874
+ if (!flushScheduledQuery()) repaint();
1875
+ return true;
1876
+ }
1877
+ // S4: Delete clears the WHOLE query; Backspace stays character deletion.
1878
+ if (keyMatches(helpers, data, ['delete'])) {
1879
+ setQuery('');
1880
+ return true;
1881
+ }
1882
+ if (keyMatches(helpers, data, ['backspace'])) {
1883
+ setQuery(state.query.slice(0, -1));
1884
+ return true;
1885
+ }
1886
+ if (typeof data === 'string' && data.length === 1) {
1887
+ setQuery(state.query + data);
1888
+ return true;
1889
+ }
1890
+ return false;
1891
+ }
1892
+
1893
+ function handleLabelInput(data) {
1894
+ // While the labels are in flight only the exit works: toggling against an
1895
+ // unknown list would guess.
1896
+ if (labelsLoading) {
1897
+ if (keyMatches(helpers, data, ['escape'])) {
1898
+ state.mode = 'list';
1899
+ labelDraft = null;
1900
+ actionError = null;
1901
+ repaint();
1902
+ }
1903
+ return true;
1904
+ }
1905
+ const names = repoLabels.map((l) => l.name);
1906
+ const createAllowed = Boolean(labelDraft && labelDraft.createAllowed);
1907
+ const rows = createAllowed ? names.length + 1 : names.length;
1908
+ if (state.labelCreating) {
1909
+ if (keyMatches(helpers, data, ['enter'])) {
1910
+ const name = state.createName.trim();
1911
+ if (name) {
1912
+ if (!names.includes(name) && !labelDraft.created.includes(name)) labelDraft.created.push(name);
1913
+ labelDraft.selected.add(name);
1914
+ }
1915
+ state.labelCreating = false;
1916
+ state.createName = '';
1917
+ repaint();
1918
+ return true;
1919
+ }
1920
+ if (keyMatches(helpers, data, ['escape'])) {
1921
+ state.labelCreating = false;
1922
+ state.createName = '';
1923
+ repaint();
1924
+ return true;
1925
+ }
1926
+ if (keyMatches(helpers, data, ['backspace'])) {
1927
+ state.createName = state.createName.slice(0, -1);
1928
+ repaint();
1929
+ return true;
1930
+ }
1931
+ if (typeof data === 'string' && data.length === 1) {
1932
+ state.createName += data;
1933
+ repaint();
1934
+ return true;
1935
+ }
1936
+ return true;
1937
+ }
1938
+ if (keyMatches(helpers, data, ['ctrl+s'])) {
1939
+ applyLabels();
1940
+ return true;
1941
+ }
1942
+ if (keyMatches(helpers, data, ['escape'])) {
1943
+ state.mode = 'list';
1944
+ labelDraft = null;
1945
+ actionError = null;
1946
+ repaint();
1947
+ return true;
1948
+ }
1949
+ if (keyMatches(helpers, data, ['up', 'k'])) {
1950
+ state.labelIndex = (state.labelIndex - 1 + rows) % rows;
1951
+ repaint();
1952
+ return true;
1953
+ }
1954
+ if (keyMatches(helpers, data, ['down', 'j'])) {
1955
+ state.labelIndex = (state.labelIndex + 1) % rows;
1956
+ repaint();
1957
+ return true;
1958
+ }
1959
+ if (keyMatches(helpers, data, ['space', ' ', 'enter'])) {
1960
+ if (createAllowed && state.labelIndex === rows - 1) {
1961
+ state.labelCreating = true;
1962
+ state.createName = '';
1963
+ } else {
1964
+ const name = names[state.labelIndex];
1965
+ if (name) {
1966
+ if (labelDraft.selected.has(name)) labelDraft.selected.delete(name);
1967
+ else labelDraft.selected.add(name);
1968
+ }
1969
+ }
1970
+ repaint();
1971
+ return true;
1972
+ }
1973
+ if (keyMatches(helpers, data, ['n'])) {
1974
+ if (!createAllowed) return true;
1975
+ state.labelCreating = true;
1976
+ state.createName = '';
1977
+ state.labelIndex = rows - 1;
1978
+ repaint();
1979
+ return true;
1980
+ }
1981
+ return false;
1982
+ }
1983
+
1984
+ function handleCommentInput(data) {
1985
+ // Discarding a NON-EMPTY draft asks once: `y` throws it away,
1986
+ // `n`/`esc` return to editing with the text intact, `ctrl+s` still
1987
+ // posts. Any other key waits out the ask instead of guessing.
1988
+ if (state.discardAsk) {
1989
+ if (keyMatches(helpers, data, ['ctrl+s'])) {
1990
+ state.discardAsk = false;
1991
+ postComment();
1992
+ return true;
1993
+ }
1994
+ if (data === 'y' || (typeof data !== 'string' && keyMatches(helpers, data, ['y']))) {
1995
+ state.mode = 'list';
1996
+ state.commentText = '';
1997
+ state.discardAsk = false;
1998
+ actionError = null;
1999
+ repaint();
2000
+ return true;
2001
+ }
2002
+ if (keyMatches(helpers, data, ['n', 'escape'])) {
2003
+ state.discardAsk = false;
2004
+ repaint();
2005
+ return true;
2006
+ }
2007
+ return true;
2008
+ }
2009
+ if (keyMatches(helpers, data, ['ctrl+s'])) {
2010
+ postComment();
2011
+ return true;
2012
+ }
2013
+ if (keyMatches(helpers, data, ['escape'])) {
2014
+ if (state.commentText !== '') {
2015
+ state.discardAsk = true;
2016
+ repaint();
2017
+ return true;
2018
+ }
2019
+ state.mode = 'list';
2020
+ state.commentText = '';
2021
+ actionError = null;
2022
+ repaint();
2023
+ return true;
2024
+ }
2025
+ if (keyMatches(helpers, data, ['enter'])) {
2026
+ state.commentText += '\n';
2027
+ repaint();
2028
+ return true;
2029
+ }
2030
+ if (keyMatches(helpers, data, ['backspace'])) {
2031
+ state.commentText = state.commentText.slice(0, -1);
2032
+ repaint();
2033
+ return true;
2034
+ }
2035
+ if (typeof data === 'string' && data.length === 1) {
2036
+ state.commentText += data;
2037
+ repaint();
2038
+ return true;
2039
+ }
2040
+ return false;
2041
+ }
2042
+
2043
+ function handleInput(data) {
2044
+ if (isKeyRelease(helpers, data)) return;
2045
+ // S10: a pending write owns the keyboard. No second save, no page or tab
2046
+ // move, no typing and no close until the server answers, so the visible
2047
+ // busy state is always the truth about what is happening.
2048
+ if (state.saving !== null) return;
2049
+ // A finished action's notice lives until the next key.
2050
+ notice = null;
2051
+ if (state.searching) {
2052
+ if (handleSearchInput(data)) return;
2053
+ return;
2054
+ }
2055
+ if (state.mode === 'labels') {
2056
+ if (handleLabelInput(data)) return;
2057
+ return;
2058
+ }
2059
+ if (state.mode === 'assignees') {
2060
+ handleAssigneeInput(data);
2061
+ return;
2062
+ }
2063
+ if (state.mode === 'comment') {
2064
+ if (handleCommentInput(data)) return;
2065
+ return;
2066
+ }
2067
+ if (state.mode === 'confirm') {
2068
+ if (keyMatches(helpers, data, ['enter', 'y'])) {
2069
+ confirmState();
2070
+ return;
2071
+ }
2072
+ if (keyMatches(helpers, data, ['escape', 'n', 'q'])) {
2073
+ state.mode = 'list';
2074
+ state.confirm = null;
2075
+ actionError = null;
2076
+ repaint();
2077
+ return;
2078
+ }
2079
+ return;
2080
+ }
2081
+ if (keyMatches(helpers, data, ['escape', 'ctrl+c', 'q'])) {
2082
+ close();
2083
+ return;
2084
+ }
2085
+ // D3/S10: tab and the arrows select the bucket; `]`/`[` scroll the list it
2086
+ // shows. S9: a query still inside its debounce window is settled by the
2087
+ // operation the reader asked for — the new bucket is read with that query,
2088
+ // and a list scroll can only scroll the query that was actually read, so it
2089
+ // flushes that read instead of firing an old-cursor request later.
2090
+ if (keyMatches(helpers, data, ['tab'])) {
2091
+ cancelScheduledQuery();
2092
+ switchPage(1);
2093
+ return;
2094
+ }
2095
+ if (keyMatches(helpers, data, ['shift+tab'])) {
2096
+ cancelScheduledQuery();
2097
+ switchPage(-1);
2098
+ return;
2099
+ }
2100
+ if (keyMatches(helpers, data, ['right'])) {
2101
+ cancelScheduledQuery();
2102
+ switchPage(1);
2103
+ return;
2104
+ }
2105
+ if (keyMatches(helpers, data, ['left'])) {
2106
+ cancelScheduledQuery();
2107
+ switchPage(-1);
2108
+ return;
2109
+ }
2110
+ if (keyMatches(helpers, data, [']'])) {
2111
+ if (!flushScheduledQuery()) scrollList(1);
2112
+ return;
2113
+ }
2114
+ if (keyMatches(helpers, data, ['['])) {
2115
+ if (!flushScheduledQuery()) scrollList(-1);
2116
+ return;
2117
+ }
2118
+ // Resolve printable paging before a host's case-folding list matcher.
2119
+ const scrollAction = scrollKeys.resolveScrollAction(helpers, data);
2120
+ if (scrollAction) {
2121
+ const page = Math.max(1, bodyRows - 1);
2122
+ const half = scrollAction === scrollKeys.ACTIONS.HALF_DOWN || scrollAction === scrollKeys.ACTIONS.HALF_UP;
2123
+ const down = scrollAction === scrollKeys.ACTIONS.PAGE_DOWN || scrollAction === scrollKeys.ACTIONS.HALF_DOWN;
2124
+ state.scroll = Math.max(0, state.scroll + (down ? 1 : -1) * (half ? Math.max(1, Math.floor(page / 2)) : page));
2125
+ repaint();
2126
+ return;
2127
+ }
2128
+ if (keyMatches(helpers, data, ['down', 'j'])) {
2129
+ moveSelection(1);
2130
+ return;
2131
+ }
2132
+ if (keyMatches(helpers, data, ['up', 'k'])) {
2133
+ moveSelection(-1);
2134
+ return;
2135
+ }
2136
+ if (keyMatches(helpers, data, ['end'])) {
2137
+ // S8 assigns home/end to first/last issue in the list: the same
2138
+ // behaviour as moving the selection (reset the new issue to its top).
2139
+ // Preview paging is independent from selecting the first/last issue.
2140
+ const total = filteredIssues().length;
2141
+ if (total > 0) {
2142
+ state.index = total - 1;
2143
+ state.scroll = 0;
2144
+ ensureDetail(selected());
2145
+ // S3: the last LOADED row is the end of what is known; asking for the
2146
+ // page after it keeps `end` meaningful on a long server list.
2147
+ loadMoreRows();
2148
+ }
2149
+ repaint();
2150
+ return;
2151
+ }
2152
+ if (keyMatches(helpers, data, ['home'])) {
2153
+ const total = filteredIssues().length;
2154
+ if (total > 0) {
2155
+ state.index = 0;
2156
+ state.listStart = 0;
2157
+ ensureDetail(selected());
2158
+ }
2159
+ state.scroll = 0;
2160
+ repaint();
2161
+ return;
2162
+ }
2163
+ if (keyMatches(helpers, data, ['t', 'T', '/'])) {
2164
+ state.searching = true;
2165
+ repaint();
2166
+ return;
2167
+ }
2168
+ if (keyMatches(helpers, data, ['L'])) {
2169
+ openLabelEditor();
2170
+ return;
2171
+ }
2172
+ // S16: `a` opens the assignee toggle editor. Both the key and its footer
2173
+ // hint require canEditAssignees, so an unpermitted viewer has neither.
2174
+ if (keyMatches(helpers, data, ['a'])) {
2175
+ openAssigneeEditor();
2176
+ return;
2177
+ }
2178
+ if (keyMatches(helpers, data, ['c'])) {
2179
+ if (!selected()) return;
2180
+ state.mode = 'comment';
2181
+ state.commentText = '';
2182
+ state.discardAsk = false;
2183
+ actionError = null;
2184
+ repaint();
2185
+ return;
2186
+ }
2187
+ // x/X both close the issue (S10a: the list is open-only, so there is no
2188
+ // reopen path), each after the host's own confirm — and only for a viewer
2189
+ // whose repository permission is confirmed. Only `x` rides the legend.
2190
+ if (keyMatches(helpers, data, ['x', 'X'])) {
2191
+ openCloseConfirm();
2192
+ return;
2193
+ }
2194
+ if (keyMatches(helpers, data, ['o'])) {
2195
+ const open = actions && typeof actions.openBrowser === 'function' ? actions.openBrowser : null;
2196
+ const row = selected();
2197
+ if (typeof open === 'function' && row) {
2198
+ try {
2199
+ open(numOf(row));
2200
+ } catch {
2201
+ // Opening the browser never mutates view state.
2202
+ }
2203
+ }
2204
+ return;
2205
+ }
2206
+ // S7: Copy is gone, so `y`/`Y` are inert here. The comment discard ask and
2207
+ // the close confirmation keep their own `y` inside their own modes.
2208
+ if (keyMatches(helpers, data, ['r'])) {
2209
+ const reload = actions && typeof actions.reload === 'function' ? actions.reload : null;
2210
+ if (serverMode) {
2211
+ // S5/S10: refresh re-reads the list freshly, and the preview with it, so
2212
+ // an edit or comment can never hide behind cached rows — even when the
2213
+ // fresh read carries the same issues. S9: a query nobody has read yet IS
2214
+ // this refresh — flushing it reads the first page of that query once,
2215
+ // and never a second time from the cancelled timer. S3: the rows the
2216
+ // reader already scrolled past are read again from the head, so a
2217
+ // refresh never leaves a half-list behind.
2218
+ if (flushScheduledQuery()) return;
2219
+ requestBucketPage(state.page, { keepSelection: true });
2220
+ return;
2221
+ }
2222
+ if (loaderMode) {
2223
+ requestPages(reload || loadPageFn);
2224
+ return;
2225
+ }
2226
+ if (typeof reload !== 'function') {
2227
+ // No list seam to re-read: the detail is what a refresh can still read
2228
+ // again, and it is where the repository permission comes from, so `r`
2229
+ // never leaves a stale capability behind.
2230
+ refreshSelectedDetail();
2231
+ return;
2232
+ }
2233
+ let result = null;
2234
+ try {
2235
+ result = reload();
2236
+ } catch {
2237
+ return;
2238
+ }
2239
+ settleAsync(result, (settled) => {
2240
+ applyLoadedPages(settled);
2241
+ repaint();
2242
+ }, (error) => {
2243
+ actionError = error;
2244
+ repaint();
2245
+ });
2246
+ }
2247
+ }
2248
+
2249
+ function invalidate() {}
2250
+
2251
+ ensureDetail(selected());
2252
+ if (loaderMode) requestPages(loadPageFn);
2253
+ else if (serverMode) {
2254
+ requestBucketPage(state.page, {});
2255
+ // `prefetchBuckets` (the interactive host sets it): every tab is read ONCE
2256
+ // when the reader opens. The reported defect was a tab showing `(0)` for
2257
+ // issues the repository does have, because only the tab in view had ever
2258
+ // been fetched, and every switch then refetched. The reads are independent
2259
+ // and the visible one never waits for the rest; the row limit already comes
2260
+ // from the real body height (listBodyRows), and further pages are appended
2261
+ // only when the reader scrolls to them (S3). A caller that drives the view
2262
+ // one bucket at a time keeps the lazy default.
2263
+ if (prefetchBuckets) {
2264
+ pages.forEach((page, index) => {
2265
+ if (index === state.page || Array.isArray(page.issues)) return;
2266
+ requestBucketPage(index, {});
2267
+ });
2268
+ }
2269
+ }
2270
+
2271
+ return { render, handleInput, invalidate, state };
2272
+ }
2273
+
2274
+ module.exports = { createIssuesView, FOOTER_KEYS, SEARCH_KEYS };