@nbtca/prompt 1.4.2 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +27 -58
  2. package/SECURITY.md +16 -45
  3. package/dist/app/app.js +167 -64
  4. package/dist/app/chrome.js +67 -50
  5. package/dist/app/fields/list-field.js +12 -25
  6. package/dist/app/fields/text-field.js +3 -8
  7. package/dist/app/frame.js +16 -23
  8. package/dist/app/keys.js +110 -2
  9. package/dist/app/views/docs-render.js +34 -26
  10. package/dist/app/views/docs.js +280 -68
  11. package/dist/app/views/events-render.js +21 -27
  12. package/dist/app/views/events.js +57 -33
  13. package/dist/app/views/home.js +67 -47
  14. package/dist/app/views/schedule-grid-cursor.js +9 -18
  15. package/dist/app/views/schedule-render.js +51 -74
  16. package/dist/app/views/schedule.js +246 -101
  17. package/dist/app/views/settings-render.js +8 -19
  18. package/dist/app/views/settings.js +92 -17
  19. package/dist/auth/cookie-transport.js +31 -32
  20. package/dist/auth/errors.js +3 -1
  21. package/dist/auth/nbt-auth.js +42 -25
  22. package/dist/auth/session-store.js +17 -9
  23. package/dist/cli.js +570 -0
  24. package/dist/config/data.js +9 -11
  25. package/dist/config/preferences.js +21 -7
  26. package/dist/core/calendar-day.js +37 -0
  27. package/dist/core/canvas.js +1 -0
  28. package/dist/core/capabilities.js +6 -3
  29. package/dist/core/components/confirm.js +9 -8
  30. package/dist/core/components/menu.js +64 -38
  31. package/dist/core/components/messages.js +12 -4
  32. package/dist/core/components/painter.js +3 -1
  33. package/dist/core/components/spinner.js +34 -7
  34. package/dist/core/components/text-input.js +24 -18
  35. package/dist/core/icons.js +2 -2
  36. package/dist/core/logo.js +23 -5
  37. package/dist/core/motion.js +25 -19
  38. package/dist/core/text.js +186 -69
  39. package/dist/core/theme.js +0 -28
  40. package/dist/core/transitions.js +2 -2
  41. package/dist/core/ui.js +15 -13
  42. package/dist/core/vim-keys.js +156 -19
  43. package/dist/features/about.js +23 -0
  44. package/dist/features/calendar-heatmap.js +16 -40
  45. package/dist/features/calendar-query.js +1 -2
  46. package/dist/features/calendar-store.js +27 -0
  47. package/dist/features/calendar.js +66 -190
  48. package/dist/features/docs-client.js +225 -0
  49. package/dist/features/docs.js +615 -298
  50. package/dist/features/links.js +44 -29
  51. package/dist/features/schedule-render.js +65 -101
  52. package/dist/features/schedule-store.js +51 -9
  53. package/dist/features/schedule-view.js +46 -213
  54. package/dist/features/status.js +117 -60
  55. package/dist/features/student-timetable.js +74 -97
  56. package/dist/features/theme.js +9 -5
  57. package/dist/features/timetable-sanitize.js +40 -0
  58. package/dist/features/update.js +12 -29
  59. package/dist/i18n/index.js +83 -19
  60. package/dist/i18n/locales/en.json +8 -3
  61. package/dist/i18n/locales/zh.json +8 -3
  62. package/dist/index.js +6 -474
  63. package/dist/logo/ca-dotmatrix.txt +16 -18
  64. package/dist/main.js +7 -48
  65. package/package.json +28 -18
  66. package/bin/nbtca-welcome.js +0 -2
  67. package/dist/core/components/screen.js +0 -18
  68. package/dist/core/menu.js +0 -68
  69. package/dist/features/schedule-query.js +0 -47
  70. package/dist/features/settings.js +0 -127
  71. package/dist/logo/ca-logo.png +0 -0
@@ -4,8 +4,9 @@ import { TextField } from '../fields/text-field.js';
4
4
  import { renderDocs } from './docs-render.js';
5
5
  import { setVimKeysActive } from '../../core/vim-keys.js';
6
6
  import { pickIcon } from '../../core/icons.js';
7
- import { getCurrentLanguage, t } from '../../i18n/index.js';
8
- import { localizeDocSections, fetchSections, fetchAllDocs, getArchivedGroups, cleanFileName, displayDocTitle, loadDocForReader, openDocsInBrowser, clearDocsCache, } from '../../features/docs.js';
7
+ import { fmt, getCurrentLanguage, t } from '../../i18n/index.js';
8
+ import { sanitizeTerminalLine, truncate } from '../../core/text.js';
9
+ import { localizeDocSections, fetchSections, fetchDocMetadata, fetchSectionMetadata, searchDocuments, getArchivedGroups, displayDocTitle, loadDocForReader, openDocsInBrowser, docsUrlFromPath, clearDocsCache, } from '../../features/docs.js';
9
10
  let state = { mode: 'loading' };
10
11
  let sections = [];
11
12
  let archivedGroups = new Map();
@@ -14,19 +15,59 @@ let loadedLanguage = null;
14
15
  let currentSectionKey = null;
15
16
  let currentArchivedGroupKey = null;
16
17
  let currentSearchResults = [];
17
- // In-app reader navigation: readerCurrentPath is the doc on screen right
18
- // now; readerNavStack holds the paths of docs visited before it (pushed
19
- // only when following a link forward, popped on Esc); readerPrevState is
20
- // whichever file-listing state (files/archivedFiles/searchResults) the
21
- // reader was entered from, restored once the nav stack empties.
18
+ let sectionsRequestId = 0;
19
+ let metadataRequestId = 0;
20
+ let searchRequestId = 0;
22
21
  let readerCurrentPath = null;
23
22
  let readerNavStack = [];
24
23
  let readerPrevState = null;
25
24
  let readerLoadingPrevState = null;
26
25
  let readerRequestId = 0;
26
+ let lifecycleGeneration = 0;
27
+ const DOC_HINT_WIDTH = 44;
28
+ function isLifecycleActive(ctx, generation) {
29
+ return generation === lifecycleGeneration && ctx.signal?.aborted !== true;
30
+ }
27
31
  function backLabel() {
28
32
  return t().common.back;
29
33
  }
34
+ function optionalHint(hint) {
35
+ return hint === undefined ? {} : { hint };
36
+ }
37
+ function docHint(value) {
38
+ return value ? truncate(value, DOC_HINT_WIDTH) : undefined;
39
+ }
40
+ function withoutReaderLinksField(value) {
41
+ const next = { ...value };
42
+ delete next.readerLinksField;
43
+ return next;
44
+ }
45
+ function withoutErrorMessage(value) {
46
+ const next = { ...value };
47
+ delete next.errorMessage;
48
+ return next;
49
+ }
50
+ async function runBrowserOpen(ctx, path) {
51
+ let opened;
52
+ await ctx.runClassic(async () => {
53
+ opened =
54
+ path === undefined
55
+ ? await openDocsInBrowser(undefined, ctx.signal)
56
+ : await openDocsInBrowser(path, ctx.signal);
57
+ });
58
+ return opened;
59
+ }
60
+ async function openBrowserFromView(ctx, path, generation = lifecycleGeneration) {
61
+ const opened = await runBrowserOpen(ctx, path);
62
+ if (!isLifecycleActive(ctx, generation) || opened === true)
63
+ return;
64
+ const url = docsUrlFromPath(path);
65
+ state = {
66
+ ...state,
67
+ errorMessage: sanitizeTerminalLine(`${t().docs.browserError}. ${fmt(t().links.openManually, { url })}`),
68
+ };
69
+ ctx.rerender();
70
+ }
30
71
  function buildSectionsField() {
31
72
  const trans = t();
32
73
  const options = [
@@ -39,12 +80,16 @@ function buildSectionsField() {
39
80
  }
40
81
  function buildFilesField(section, maxVisible, initialIndex = 0) {
41
82
  const trans = t();
42
- const isIndex = (f) => f.name === 'index.md' || f.name.startsWith('index.');
83
+ const isIndex = (file) => file.name === 'index.md' || file.name.startsWith('index.');
43
84
  const index = section.files.find(isIndex);
44
85
  const files = section.files.filter((f) => !isIndex(f));
45
86
  const options = [
46
87
  ...(index ? [{ value: index.path, label: trans.docs.overviewLabel }] : []),
47
- ...files.map((f) => ({ value: f.path, label: displayDocTitle(f.path, f.name) })),
88
+ ...files.map((file) => ({
89
+ value: file.path,
90
+ label: displayDocTitle(file.name, file.title),
91
+ ...optionalHint(docHint(file.summary)),
92
+ })),
48
93
  { value: '__back__', label: backLabel() },
49
94
  ];
50
95
  return new ListField({ title: section.label, options, maxVisible, initialIndex });
@@ -63,7 +108,7 @@ function buildArchivedGroupsField(groups, maxVisible, initialIndex = 0) {
63
108
  return a.localeCompare(b);
64
109
  });
65
110
  const options = [
66
- ...sortedKeys.map((k) => ({ value: k, label: k, hint: String(groups.get(k).length) })),
111
+ ...sortedKeys.map((k) => ({ value: k, label: k, hint: String(groups.get(k)?.length ?? 0) })),
67
112
  { value: '__back__', label: backLabel() },
68
113
  ];
69
114
  return new ListField({ title: trans.docs.categoryArchived, options, maxVisible, initialIndex });
@@ -74,11 +119,22 @@ function buildArchivedFilesField(groupKey, groupFiles, maxVisible, initialIndex
74
119
  const options = [
75
120
  ...groupFiles.map((f) => {
76
121
  const sub = f.path.split('/').slice(2, -1).join('/');
77
- return { value: f.path, label: cleanFileName(f.name), hint: subDirs.size > 1 ? sub : undefined };
122
+ return {
123
+ value: f.path,
124
+ label: displayDocTitle(f.name, f.title),
125
+ ...optionalHint(docHint(subDirs.size > 1
126
+ ? [sanitizeTerminalLine(sub), f.summary].filter(Boolean).join(' · ')
127
+ : f.summary)),
128
+ };
78
129
  }),
79
130
  { value: '__back__', label: backLabel() },
80
131
  ];
81
- return new ListField({ title: `${trans.docs.categoryArchived} · ${groupKey}`, options, maxVisible, initialIndex });
132
+ return new ListField({
133
+ title: `${trans.docs.categoryArchived} · ${groupKey}`,
134
+ options,
135
+ maxVisible,
136
+ initialIndex,
137
+ });
82
138
  }
83
139
  function buildReaderLinksField(links, maxVisible, initialIndex = 0) {
84
140
  const trans = t();
@@ -93,8 +149,12 @@ function buildSearchResultsField(matches, maxVisible, initialIndex = 0) {
93
149
  const options = [
94
150
  ...matches.map((result) => ({
95
151
  value: result.path,
96
- label: displayDocTitle(result.path, result.name),
97
- hint: result.path.includes('/') ? result.path.split('/').slice(0, -1).join('/') : undefined,
152
+ label: displayDocTitle(result.name, result.title),
153
+ ...optionalHint(docHint(result.excerpt ||
154
+ result.summary ||
155
+ (result.path.includes('/')
156
+ ? sanitizeTerminalLine(result.path.split('/').slice(0, -1).join('/'))
157
+ : undefined))),
98
158
  })),
99
159
  { value: '__back__', label: backLabel() },
100
160
  ];
@@ -107,7 +167,10 @@ function relocalizeStateFields(value, maxVisible) {
107
167
  if (value.mode === 'files' && currentSectionKey) {
108
168
  const section = sections.find((candidate) => candidate.key === currentSectionKey);
109
169
  return section
110
- ? { ...value, filesField: buildFilesField(section, maxVisible, value.filesField?.selectedIndex) }
170
+ ? {
171
+ ...value,
172
+ filesField: buildFilesField(section, maxVisible, value.filesField?.selectedIndex),
173
+ }
111
174
  : value;
112
175
  }
113
176
  if (value.mode === 'archivedGroups') {
@@ -142,11 +205,115 @@ function goToSections() {
142
205
  currentSearchResults = [];
143
206
  state = { mode: 'sections', sectionsField: buildSectionsField() };
144
207
  }
145
- /** Enters (or re-enters) the reader on `path`. `pushCurrent` distinguishes
146
- * following a link forward (push readerCurrentPath so Esc can return to it)
147
- * from navigating backward or entering fresh from a file list (nothing to
148
- * push -- the caller has already saved/cleared readerPrevState itself). */
208
+ function replaceSection(section) {
209
+ sections = sections.map((current) => (current.key === section.key ? section : current));
210
+ }
211
+ async function openSectionFiles(ctx, section) {
212
+ const generation = lifecycleGeneration;
213
+ if (!isLifecycleActive(ctx, generation))
214
+ return;
215
+ const requestId = ++metadataRequestId;
216
+ currentSectionKey = section.key;
217
+ state = {
218
+ mode: 'files',
219
+ filesField: buildFilesField(section, computeMaxVisible(ctx.bodyRows)),
220
+ };
221
+ ctx.rerender();
222
+ try {
223
+ const hydrated = await fetchSectionMetadata(section, ctx.signal);
224
+ if (!isLifecycleActive(ctx, generation) ||
225
+ requestId !== metadataRequestId ||
226
+ state.mode !== 'files' ||
227
+ currentSectionKey !== section.key)
228
+ return;
229
+ const localized = localizeDocSections([hydrated], t())[0] ?? hydrated;
230
+ replaceSection(localized);
231
+ state = {
232
+ mode: 'files',
233
+ filesField: buildFilesField(localized, computeMaxVisible(ctx.bodyRows), state.filesField?.selectedIndex),
234
+ };
235
+ }
236
+ catch {
237
+ if (!isLifecycleActive(ctx, generation) ||
238
+ requestId !== metadataRequestId ||
239
+ state.mode !== 'files' ||
240
+ currentSectionKey !== section.key)
241
+ return;
242
+ state = { ...state, errorMessage: t().docs.loadError };
243
+ }
244
+ if (isLifecycleActive(ctx, generation))
245
+ ctx.rerender();
246
+ }
247
+ async function openArchivedFiles(ctx, groupKey, groupFiles) {
248
+ const generation = lifecycleGeneration;
249
+ if (!isLifecycleActive(ctx, generation))
250
+ return;
251
+ const requestId = ++metadataRequestId;
252
+ currentArchivedGroupKey = groupKey;
253
+ state = {
254
+ mode: 'archivedFiles',
255
+ archivedFilesField: buildArchivedFilesField(groupKey, groupFiles, computeMaxVisible(ctx.bodyRows)),
256
+ };
257
+ ctx.rerender();
258
+ try {
259
+ const hydrated = await fetchDocMetadata(groupFiles, ctx.signal);
260
+ if (!isLifecycleActive(ctx, generation) ||
261
+ requestId !== metadataRequestId ||
262
+ state.mode !== 'archivedFiles' ||
263
+ currentArchivedGroupKey !== groupKey)
264
+ return;
265
+ archivedGroups.set(groupKey, hydrated);
266
+ state = {
267
+ mode: 'archivedFiles',
268
+ archivedFilesField: buildArchivedFilesField(groupKey, hydrated, computeMaxVisible(ctx.bodyRows), state.archivedFilesField?.selectedIndex),
269
+ };
270
+ }
271
+ catch {
272
+ if (!isLifecycleActive(ctx, generation) ||
273
+ requestId !== metadataRequestId ||
274
+ state.mode !== 'archivedFiles' ||
275
+ currentArchivedGroupKey !== groupKey)
276
+ return;
277
+ state = {
278
+ ...state,
279
+ errorMessage: t().docs.loadError,
280
+ };
281
+ }
282
+ if (isLifecycleActive(ctx, generation))
283
+ ctx.rerender();
284
+ }
285
+ async function runSearch(ctx, query) {
286
+ const generation = lifecycleGeneration;
287
+ if (!isLifecycleActive(ctx, generation))
288
+ return;
289
+ const requestId = ++searchRequestId;
290
+ state = { mode: 'searchLoading' };
291
+ ctx.rerender();
292
+ try {
293
+ const matches = await searchDocuments(query, ctx.signal);
294
+ if (!isLifecycleActive(ctx, generation) || requestId !== searchRequestId)
295
+ return;
296
+ currentSearchResults = matches;
297
+ state = {
298
+ mode: 'searchResults',
299
+ searchResultsEmpty: matches.length === 0,
300
+ searchResultsField: buildSearchResultsField(matches, computeMaxVisible(ctx.bodyRows)),
301
+ };
302
+ }
303
+ catch {
304
+ if (!isLifecycleActive(ctx, generation) || requestId !== searchRequestId)
305
+ return;
306
+ currentSearchResults = [];
307
+ goToSections();
308
+ state = { ...state, errorMessage: t().docs.loadError };
309
+ }
310
+ if (isLifecycleActive(ctx, generation))
311
+ ctx.rerender();
312
+ }
149
313
  async function openInReader(ctx, path, pushCurrent) {
314
+ const generation = lifecycleGeneration;
315
+ if (!isLifecycleActive(ctx, generation))
316
+ return;
150
317
  const requestId = ++readerRequestId;
151
318
  const previousState = state;
152
319
  const previousPath = readerCurrentPath;
@@ -154,30 +321,33 @@ async function openInReader(ctx, path, pushCurrent) {
154
321
  state = { mode: 'readerLoading' };
155
322
  ctx.rerender();
156
323
  try {
157
- const doc = await loadDocForReader(path);
158
- if (requestId !== readerRequestId)
324
+ const doc = await loadDocForReader(path, ctx.signal);
325
+ if (!isLifecycleActive(ctx, generation) || requestId !== readerRequestId)
159
326
  return;
160
327
  if (pushCurrent && previousPath)
161
328
  readerNavStack.push(previousPath);
162
329
  readerCurrentPath = path;
163
330
  readerLoadingPrevState = null;
164
- state = { mode: 'reader', readerTitle: doc.title, readerLines: doc.lines, readerLinks: doc.links };
331
+ state = {
332
+ mode: 'reader',
333
+ readerTitle: doc.title,
334
+ readerLines: doc.lines,
335
+ readerLinks: doc.links,
336
+ };
165
337
  ctx.resetScroll();
166
338
  }
167
339
  catch {
168
- if (requestId !== readerRequestId)
340
+ if (!isLifecycleActive(ctx, generation) || requestId !== readerRequestId)
169
341
  return;
170
342
  readerLoadingPrevState = null;
171
343
  const fallbackState = pushCurrent && previousState.mode === 'reader'
172
- ? { ...previousState, readerLinksField: undefined }
344
+ ? withoutReaderLinksField(previousState)
173
345
  : previousState;
174
346
  state = { ...fallbackState, errorMessage: t().docs.loadError };
175
347
  }
176
- ctx.rerender();
348
+ if (isLifecycleActive(ctx, generation))
349
+ ctx.rerender();
177
350
  }
178
- /** Enters the reader from a file-listing mode (files/archivedFiles/
179
- * searchResults) -- saves that listing so Esc can restore it once the nav
180
- * stack (built by following links from here) empties back out. */
181
351
  function enterReaderFrom(ctx, path) {
182
352
  readerPrevState = state;
183
353
  readerNavStack = [];
@@ -188,6 +358,9 @@ export const docsView = {
188
358
  id: 'docs',
189
359
  title: t().menu.docs,
190
360
  async load(ctx) {
361
+ const generation = loaded ? lifecycleGeneration : ++lifecycleGeneration;
362
+ if (!isLifecycleActive(ctx, generation))
363
+ return;
191
364
  if (loaded) {
192
365
  const language = getCurrentLanguage();
193
366
  if (loadedLanguage !== language) {
@@ -201,23 +374,36 @@ export const docsView = {
201
374
  }
202
375
  return;
203
376
  }
377
+ const requestId = ++sectionsRequestId;
204
378
  state = { mode: 'loading' };
205
379
  ctx.rerender();
206
380
  try {
207
- sections = await fetchSections();
381
+ const nextSections = await fetchSections(ctx.signal);
382
+ if (!isLifecycleActive(ctx, generation) || requestId !== sectionsRequestId)
383
+ return;
384
+ sections = nextSections;
208
385
  loaded = true;
209
386
  loadedLanguage = getCurrentLanguage();
210
387
  goToSections();
211
388
  }
212
389
  catch {
390
+ if (!isLifecycleActive(ctx, generation) || requestId !== sectionsRequestId)
391
+ return;
213
392
  state = { mode: 'error', errorMessage: t().docs.loadError };
214
393
  }
215
- ctx.rerender();
394
+ if (isLifecycleActive(ctx, generation))
395
+ ctx.rerender();
396
+ },
397
+ dispose() {
398
+ lifecycleGeneration += 1;
399
+ sectionsRequestId += 1;
400
+ metadataRequestId += 1;
401
+ searchRequestId += 1;
402
+ readerRequestId += 1;
403
+ clearDocsCache();
404
+ setVimKeysActive(true);
216
405
  },
217
406
  render(ctx) {
218
- // Sync every visible field's scroll window to the *current* terminal
219
- // size on every frame (not just construction time) — this is what
220
- // keeps a long list correctly windowed across a live resize.
221
407
  const maxVisible = computeMaxVisible(ctx.bodyRows);
222
408
  state.filesField?.setMaxVisible(maxVisible);
223
409
  state.archivedGroupsField?.setMaxVisible(maxVisible);
@@ -226,13 +412,24 @@ export const docsView = {
226
412
  state.readerLinksField?.setMaxVisible(maxVisible);
227
413
  return renderDocs(state, ctx.size.cols, ctx.bodyRows);
228
414
  },
415
+ isBusy() {
416
+ return (state.mode === 'loading' || state.mode === 'searchLoading' || state.mode === 'readerLoading');
417
+ },
229
418
  capturesInput() {
230
419
  return state.mode === 'search';
231
420
  },
421
+ capturesPageKeys() {
422
+ return (state.mode === 'sections' ||
423
+ state.mode === 'files' ||
424
+ state.mode === 'archivedGroups' ||
425
+ state.mode === 'archivedFiles' ||
426
+ state.mode === 'searchResults' ||
427
+ (state.mode === 'reader' && state.readerLinksField !== undefined));
428
+ },
232
429
  footerHint(tabCount, cols = Number.POSITIVE_INFINITY) {
233
430
  if (state.mode === 'search')
234
431
  return captureFooterHint(cols);
235
- if (state.mode === 'loading' || state.mode === 'error')
432
+ if (state.mode === 'loading' || state.mode === 'searchLoading' || state.mode === 'error')
236
433
  return passiveFooterHint(tabCount, cols);
237
434
  if (state.mode === 'readerLoading') {
238
435
  return fitFooterHint(cols, `${digitTabHint(tabCount)}q ${t().menu.hintQuit}`, `${digitTabHint(tabCount)}q`, 'q');
@@ -250,6 +447,11 @@ export const docsView = {
250
447
  return undefined;
251
448
  },
252
449
  handleBack(ctx) {
450
+ if (state.mode === 'searchLoading') {
451
+ searchRequestId++;
452
+ goToSections();
453
+ return true;
454
+ }
253
455
  if (state.mode === 'reader' || state.mode === 'readerLoading') {
254
456
  if (state.mode === 'readerLoading') {
255
457
  const previousState = readerLoadingPrevState;
@@ -261,7 +463,7 @@ export const docsView = {
261
463
  return true;
262
464
  }
263
465
  if (state.readerLinksField) {
264
- state = { ...state, readerLinksField: undefined };
466
+ state = withoutReaderLinksField(state);
265
467
  return true;
266
468
  }
267
469
  const prevPath = readerNavStack.pop();
@@ -278,7 +480,10 @@ export const docsView = {
278
480
  return false;
279
481
  }
280
482
  if (state.mode === 'archivedFiles') {
281
- state = { mode: 'archivedGroups', archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)) };
483
+ state = {
484
+ mode: 'archivedGroups',
485
+ archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)),
486
+ };
282
487
  return true;
283
488
  }
284
489
  if (state.mode === 'search') {
@@ -286,7 +491,9 @@ export const docsView = {
286
491
  goToSections();
287
492
  return true;
288
493
  }
289
- if (state.mode === 'files' || state.mode === 'archivedGroups' || state.mode === 'searchResults') {
494
+ if (state.mode === 'files' ||
495
+ state.mode === 'archivedGroups' ||
496
+ state.mode === 'searchResults') {
290
497
  goToSections();
291
498
  return true;
292
499
  }
@@ -294,7 +501,7 @@ export const docsView = {
294
501
  },
295
502
  handleKey(key, ctx) {
296
503
  if (state.mode !== 'error' && state.errorMessage)
297
- state = { ...state, errorMessage: undefined };
504
+ state = withoutErrorMessage(state);
298
505
  switch (state.mode) {
299
506
  case 'sections': {
300
507
  const result = state.sectionsField?.handleKey(key);
@@ -302,11 +509,21 @@ export const docsView = {
302
509
  return;
303
510
  if (result.selected === '__search__') {
304
511
  setVimKeysActive(false);
305
- state = { mode: 'search', searchField: new TextField({ message: t().docs.searchPrompt, placeholder: t().docs.searchPlaceholder, allowEmpty: true }) };
512
+ state = {
513
+ mode: 'search',
514
+ searchField: new TextField({
515
+ message: t().docs.searchPrompt,
516
+ placeholder: t().docs.searchPlaceholder,
517
+ allowEmpty: true,
518
+ }),
519
+ };
306
520
  return;
307
521
  }
308
522
  if (result.selected === '__refresh__') {
309
523
  clearDocsCache();
524
+ sectionsRequestId++;
525
+ metadataRequestId++;
526
+ searchRequestId++;
310
527
  loaded = false;
311
528
  loadedLanguage = null;
312
529
  sections = [];
@@ -319,25 +536,28 @@ export const docsView = {
319
536
  readerPrevState = null;
320
537
  readerLoadingPrevState = null;
321
538
  readerRequestId++;
322
- void docsView.load?.(ctx);
539
+ void docsView.load(ctx);
323
540
  return;
324
541
  }
325
542
  if (result.selected === '__browser__') {
326
- void ctx.runClassic(() => openDocsInBrowser());
543
+ void openBrowserFromView(ctx);
327
544
  return;
328
545
  }
329
546
  const section = sections.find((s) => s.key === result.selected);
330
547
  if (!section)
331
548
  return;
332
549
  if (section.key === 'archived') {
550
+ metadataRequestId++;
333
551
  currentSectionKey = null;
334
552
  currentArchivedGroupKey = null;
335
553
  archivedGroups = getArchivedGroups(section.files);
336
- state = { mode: 'archivedGroups', archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)) };
554
+ state = {
555
+ mode: 'archivedGroups',
556
+ archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)),
557
+ };
337
558
  }
338
559
  else {
339
- currentSectionKey = section.key;
340
- state = { mode: 'files', filesField: buildFilesField(section, computeMaxVisible(ctx.bodyRows)) };
560
+ void openSectionFiles(ctx, section);
341
561
  }
342
562
  return;
343
563
  }
@@ -360,9 +580,8 @@ export const docsView = {
360
580
  goToSections();
361
581
  return;
362
582
  }
363
- currentArchivedGroupKey = result.selected;
364
583
  const groupFiles = archivedGroups.get(result.selected) ?? [];
365
- state = { mode: 'archivedFiles', archivedFilesField: buildArchivedFilesField(result.selected, groupFiles, computeMaxVisible(ctx.bodyRows)) };
584
+ void openArchivedFiles(ctx, result.selected, groupFiles);
366
585
  return;
367
586
  }
368
587
  case 'archivedFiles': {
@@ -371,7 +590,10 @@ export const docsView = {
371
590
  return;
372
591
  if (result.selected === '__back__') {
373
592
  currentArchivedGroupKey = null;
374
- state = { mode: 'archivedGroups', archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)) };
593
+ state = {
594
+ mode: 'archivedGroups',
595
+ archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)),
596
+ };
375
597
  return;
376
598
  }
377
599
  enterReaderFrom(ctx, result.selected);
@@ -391,19 +613,7 @@ export const docsView = {
391
613
  goToSections();
392
614
  return;
393
615
  }
394
- void fetchAllDocs().then((all) => {
395
- const matches = all.filter((item) => item.path.toLowerCase().includes(query));
396
- currentSearchResults = matches;
397
- state = {
398
- mode: 'searchResults',
399
- searchResultsEmpty: matches.length === 0,
400
- searchResultsField: buildSearchResultsField(matches, computeMaxVisible(ctx.bodyRows)),
401
- };
402
- ctx.rerender();
403
- }).catch(() => {
404
- state = { mode: 'error', errorMessage: t().docs.loadError };
405
- ctx.rerender();
406
- });
616
+ void runSearch(ctx, query);
407
617
  }
408
618
  return;
409
619
  }
@@ -422,28 +632,30 @@ export const docsView = {
422
632
  if (state.readerLinksField) {
423
633
  const result = state.readerLinksField.handleKey(key);
424
634
  if (result.cancelled || result.selected === '__back__') {
425
- state = { ...state, readerLinksField: undefined };
635
+ state = withoutReaderLinksField(state);
426
636
  return;
427
637
  }
428
638
  if (result.selected)
429
639
  void openInReader(ctx, result.selected, true);
430
640
  return;
431
641
  }
432
- // 'f' (Vimium/vim-browser-extension convention: "follow a link"),
433
- // not 'l' -- core/vim-keys.ts already reserves 'l' globally,
434
- // ranger-style, as an alias for Enter/confirm (vimActive defaults
435
- // to true), so a literal 'l' keypress never even reaches here.
436
642
  if (key === 'f' && (state.readerLinks?.length ?? 0) > 0) {
437
- state = { ...state, readerLinksField: buildReaderLinksField(state.readerLinks ?? [], computeMaxVisible(ctx.bodyRows)) };
643
+ state = {
644
+ ...state,
645
+ readerLinksField: buildReaderLinksField(state.readerLinks ?? [], computeMaxVisible(ctx.bodyRows)),
646
+ };
438
647
  return;
439
648
  }
440
649
  if (key === 'b') {
441
- void ctx.runClassic(() => openDocsInBrowser(readerCurrentPath ?? undefined));
650
+ void openBrowserFromView(ctx, readerCurrentPath ?? undefined);
442
651
  return;
443
652
  }
444
653
  return;
445
654
  }
446
- default:
655
+ case 'error':
656
+ case 'loading':
657
+ case 'readerLoading':
658
+ case 'searchLoading':
447
659
  return;
448
660
  }
449
661
  },
@@ -3,29 +3,15 @@ import { t } from '../../i18n/index.js';
3
3
  import { renderListFieldWithContext } from '../fields/list-field.js';
4
4
  import { renderCountdownBanner, renderEventBrief } from '../../features/calendar.js';
5
5
  import { renderHeatmap } from '../../features/calendar-heatmap.js';
6
- import { visualWidth, wrapAnsiToVisualWidth } from '../../core/text.js';
6
+ import { wrapAnsiWithIndent } from '../../core/text.js';
7
+ import { loadingLines } from '../../core/components/spinner.js';
7
8
  function wrappedIndentedLines(label, cols, style) {
8
- const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols ?? 1)) : Number.POSITIVE_INFINITY;
9
- const styled = style(label);
10
- const styledWidth = visualWidth(styled);
11
- const preferredIndent = visualWidth(space.indent) < width ? space.indent : '';
12
- const indent = preferredIndent
13
- && styledWidth > width - visualWidth(preferredIndent)
14
- && styledWidth <= width
15
- ? ''
16
- : preferredIndent;
17
- const contentWidth = Math.max(1, width - visualWidth(indent));
18
- return wrapAnsiToVisualWidth(styled, contentWidth).map((line) => `${indent}${line}`);
9
+ return wrapAnsiWithIndent(style(label), cols ?? Number.POSITIVE_INFINITY, space.indent);
19
10
  }
20
11
  function wrappedRenderedLine(line, cols) {
21
12
  const content = line.startsWith(space.indent) ? line.slice(space.indent.length) : line;
22
13
  return wrappedIndentedLines(content, cols, (value) => value);
23
14
  }
24
- // Lines a fully-expanded hub needs: banner+blank (2) + heatmap+blank (12) +
25
- // recent-activity heading+up to 5 events+blank (7) + hubField
26
- // (title+blank+6 options, 8) = 29. Below this, a terminal can't fit the
27
- // heatmap without pushing the menu into scroll territory — better to keep
28
- // it as the existing drill-down destination than show a truncated grid.
29
15
  const EXPANDED_HUB_MIN_BODY_ROWS = 29;
30
16
  function renderHubBody(state, now, bodyRows, cols) {
31
17
  const trans = t();
@@ -38,7 +24,10 @@ function renderHubBody(state, now, bodyRows, cols) {
38
24
  lines.push(...banner.split('\n'), '');
39
25
  const buckets = state.heatmapBuckets;
40
26
  if (bodyRows >= EXPANDED_HUB_MIN_BODY_ROWS && buckets && buckets.length > 0) {
41
- lines.push(...renderHeatmap(buckets, now, { color: true, cols }).split('\n'));
27
+ lines.push(...renderHeatmap(buckets, now, {
28
+ color: true,
29
+ ...(cols === undefined ? {} : { cols }),
30
+ }).split('\n'));
42
31
  lines.push('');
43
32
  }
44
33
  if (state.recentEvents && state.recentEvents.length > 0) {
@@ -73,16 +62,15 @@ export function renderEvents(state, now, bodyRows = 100, cols) {
73
62
  const trans = t();
74
63
  switch (state.mode) {
75
64
  case 'loading':
76
- return wrappedIndentedLines(trans.calendar.loading, cols, type.hint);
65
+ return loadingLines(trans.calendar.loading, cols);
77
66
  case 'hub':
78
67
  return renderHubBody(state, now, bodyRows, cols);
79
68
  case 'heatmap':
80
- // renderHeatmap() already prints its own title (space.indent +
81
- // type.heading), so this mode doesn't add a second heading on top —
82
- // unlike Schedule's 'week'/'unresolved' modes, which wrap a
83
- // title-less renderer.
84
69
  return state.heatmapBuckets && state.heatmapBuckets.length > 0
85
- ? renderHeatmap(state.heatmapBuckets, now, { color: true, cols }).split('\n')
70
+ ? renderHeatmap(state.heatmapBuckets, now, {
71
+ color: true,
72
+ ...(cols === undefined ? {} : { cols }),
73
+ }).split('\n')
86
74
  : wrappedIndentedLines(trans.calendar.noEvents, cols, type.hint);
87
75
  case 'list':
88
76
  return state.listField?.render(bodyRows, cols) ?? [];
@@ -92,14 +80,20 @@ export function renderEvents(state, now, bodyRows = 100, cols) {
92
80
  ...wrappedIndentedLines(state.detailMeta ?? '', cols, type.hint),
93
81
  '',
94
82
  ...(state.detailDescription
95
- ? state.detailDescription.split('\n').flatMap((line) => wrappedIndentedLines(line, cols, type.body))
83
+ ? state.detailDescription
84
+ .split('\n')
85
+ .flatMap((line) => wrappedIndentedLines(line, cols, type.body))
96
86
  : wrappedIndentedLines(trans.calendar.noDescription, cols, type.hint)),
97
87
  '',
98
- ...(state.statusMessage ? [...wrappedIndentedLines(state.statusMessage, cols, type.hint), ''] : []),
88
+ ...(state.statusMessage
89
+ ? [...wrappedIndentedLines(state.statusMessage, cols, type.hint), '']
90
+ : []),
99
91
  ];
100
92
  return state.detailField
101
93
  ? renderListFieldWithContext(context, state.detailField, bodyRows, cols)
102
- : Number.isFinite(bodyRows) ? context.slice(0, Math.max(0, Math.floor(bodyRows))) : context;
94
+ : Number.isFinite(bodyRows)
95
+ ? context.slice(0, Math.max(0, Math.floor(bodyRows)))
96
+ : context;
103
97
  }
104
98
  case 'search':
105
99
  return state.searchField?.render(cols) ?? [];