pi-odd-worktree 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +234 -2
- package/extensions/odd-worktree-tools.ts +7593 -0
- package/lib/checkout-facts.js +598 -0
- package/lib/clipboard.js +226 -0
- package/lib/gh-issues.js +1555 -0
- package/lib/issue-preview.js +273 -0
- package/lib/issues-view.js +2274 -0
- package/lib/odd-docs.js +505 -0
- package/lib/odd-markdown.js +413 -0
- package/lib/odd-view.js +990 -0
- package/lib/open-external.js +106 -0
- package/lib/preview-scroll-keys.js +192 -0
- package/lib/session-auto-name.js +292 -0
- package/lib/session-worktree-register.js +62 -0
- package/lib/tui-paint.js +273 -0
- package/lib/view-layout.js +368 -0
- package/lib/warp-create-panel.js +722 -0
- package/lib/warp-create-picker.js +906 -0
- package/lib/warp-delete-confirm.js +465 -0
- package/lib/warp-selector-view.js +1265 -0
- package/lib/worktree-card.js +1117 -0
- package/lib/worktree-changes.js +1395 -0
- package/lib/worktree-close-herdr.js +73 -0
- package/lib/worktree-git.js +1048 -0
- package/lib/worktree-hooks.js +738 -0
- package/lib/worktree-model-result.js +291 -0
- package/lib/worktree-registry.js +886 -0
- package/lib/worktree-session-guard.js +595 -0
- package/lib/worktree-symlinks.js +336 -0
- package/lib/worktree-warp-sessions.js +566 -0
- package/package.json +36 -4
- package/tools/commands.test.js +176 -0
- package/tools/fixtures/gh-api-user.json +1 -0
- package/tools/fixtures/gh-issue-list.json +1 -0
- package/tools/fixtures/gh-issue-view.json +1 -0
- package/tools/fixtures/gh-label-list.json +1 -0
- package/tools/fixtures/gh-repo-view.json +1 -0
- package/tools/fixtures/load-extension.js +66 -0
- package/tools/gh-issues-feedback.test.js +359 -0
- package/tools/gh-issues.test.js +1502 -0
- package/tools/issue-preview.test.js +216 -0
- package/tools/issues-command.test.js +1217 -0
- package/tools/issues-view.test.js +4401 -0
- package/tools/odd-docs.test.js +803 -0
- package/tools/odd-markdown.test.js +236 -0
- package/tools/odd-view.test.js +2328 -0
- package/tools/package-surface.test.js +138 -0
- package/tools/t5-session-identity.test.js +306 -0
- package/tools/warp-create-panel.test.js +185 -0
- package/tools/warp-create-picker.test.js +1357 -0
- package/tools/warp-delete-confirm.test.js +616 -0
- package/tools/warp-selector-view.test.js +1463 -0
- package/tools/worktree-changes.test.js +951 -0
- package/tools/worktree-close-herdr-async.test.js +204 -0
- package/tools/worktree-odd-migration.test.js +224 -0
- package/tools/worktree-registry-cache.test.js +163 -0
- package/tools/worktree-registry-odd.test.js +231 -0
- package/tools/worktree-registry.test.js +244 -0
- package/tools/worktree-session-guard.test.js +507 -0
- 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', '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 };
|