@nbtca/prompt 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +27 -58
  3. package/SECURITY.md +16 -45
  4. package/dist/app/app.js +53 -55
  5. package/dist/app/chrome.js +67 -50
  6. package/dist/app/fields/list-field.js +12 -25
  7. package/dist/app/fields/text-field.js +3 -8
  8. package/dist/app/frame.js +2 -21
  9. package/dist/app/keys.js +10 -2
  10. package/dist/app/views/docs-render.js +31 -24
  11. package/dist/app/views/docs.js +211 -67
  12. package/dist/app/views/events-render.js +19 -26
  13. package/dist/app/views/events.js +44 -31
  14. package/dist/app/views/home.js +33 -76
  15. package/dist/app/views/schedule-grid-cursor.js +9 -18
  16. package/dist/app/views/schedule-render.js +47 -71
  17. package/dist/app/views/schedule.js +158 -90
  18. package/dist/app/views/settings-render.js +8 -19
  19. package/dist/app/views/settings.js +93 -18
  20. package/dist/auth/cookie-transport.js +31 -32
  21. package/dist/auth/errors.js +3 -1
  22. package/dist/auth/nbt-auth.js +42 -25
  23. package/dist/auth/session-store.js +17 -9
  24. package/dist/config/data.js +10 -13
  25. package/dist/config/preferences.js +14 -7
  26. package/dist/core/calendar-day.js +37 -0
  27. package/dist/core/capabilities.js +6 -3
  28. package/dist/core/components/confirm.js +9 -8
  29. package/dist/core/components/menu.js +41 -16
  30. package/dist/core/components/messages.js +12 -4
  31. package/dist/core/components/painter.js +3 -1
  32. package/dist/core/components/spinner.js +17 -6
  33. package/dist/core/components/text-input.js +24 -18
  34. package/dist/core/icons.js +2 -2
  35. package/dist/core/logo.js +25 -21
  36. package/dist/core/motion.js +25 -19
  37. package/dist/core/text.js +182 -75
  38. package/dist/core/theme.js +0 -28
  39. package/dist/core/transitions.js +2 -2
  40. package/dist/core/ui.js +15 -30
  41. package/dist/core/vim-keys.js +9 -15
  42. package/dist/features/about.js +23 -0
  43. package/dist/features/calendar-heatmap.js +16 -40
  44. package/dist/features/calendar-query.js +1 -2
  45. package/dist/features/calendar.js +12 -185
  46. package/dist/features/docs.js +439 -320
  47. package/dist/features/schedule-render.js +65 -102
  48. package/dist/features/schedule-store.js +51 -9
  49. package/dist/features/schedule-view.js +46 -220
  50. package/dist/features/status.js +44 -59
  51. package/dist/features/student-timetable.js +73 -95
  52. package/dist/features/theme.js +6 -5
  53. package/dist/features/timetable-sanitize.js +40 -0
  54. package/dist/features/update.js +9 -37
  55. package/dist/i18n/index.js +87 -65
  56. package/dist/i18n/locales/en.json +1 -1
  57. package/dist/i18n/locales/zh.json +1 -1
  58. package/dist/index.js +85 -64
  59. package/dist/logo/ca-dotmatrix.txt +16 -18
  60. package/dist/main.js +7 -48
  61. package/package.json +30 -18
  62. package/bin/nbtca-welcome.js +0 -2
  63. package/dist/core/components/screen.js +0 -18
  64. package/dist/core/menu.js +0 -71
  65. package/dist/features/links.js +0 -39
  66. package/dist/features/schedule-query.js +0 -47
  67. package/dist/features/settings.js +0 -130
  68. package/dist/logo/ca-logo.png +0 -0
@@ -1,9 +1,4 @@
1
1
  import { renderInput, applyInputEvent, parseInputData } from '../../core/components/text-input.js';
2
- /** Non-blocking equivalent of `runTextInput`/`runSecretInput`: a view holds
3
- * one of these and drives it via `handleKey` from the app loop's single
4
- * stdin listener. Does not touch vim-key activation — the owning view is
5
- * responsible for `setVimKeysActive(false)` while a TextField is focused
6
- * (mirrors what `runTextInput` already does for the blocking widget). */
7
2
  export class TextField {
8
3
  config;
9
4
  value = '';
@@ -17,9 +12,9 @@ export class TextField {
17
12
  return renderInput({
18
13
  message: this.config.message,
19
14
  value: this.value,
20
- placeholder: this.config.placeholder,
21
- secret: this.config.secret,
22
- mask: this.config.mask,
15
+ ...(this.config.placeholder === undefined ? {} : { placeholder: this.config.placeholder }),
16
+ ...(this.config.secret === undefined ? {} : { secret: this.config.secret }),
17
+ ...(this.config.mask === undefined ? {} : { mask: this.config.mask }),
23
18
  cols,
24
19
  }).split('\n');
25
20
  }
package/dist/app/frame.js CHANGED
@@ -1,27 +1,8 @@
1
- import { visualWidth } from '../core/text.js';
1
+ import { clipAnsiToVisualWidth, visualWidth } from '../core/text.js';
2
2
  export function clipToWidth(line, cols) {
3
3
  if (visualWidth(line) <= cols)
4
4
  return line;
5
- let out = '';
6
- let w = 0;
7
- let i = 0;
8
- while (i < line.length) {
9
- const esc = line.slice(i).match(/^\x1b\[[0-9;]*m/);
10
- if (esc) {
11
- out += esc[0];
12
- i += esc[0].length;
13
- continue;
14
- }
15
- const cp = line.codePointAt(i);
16
- const ch = String.fromCodePoint(cp);
17
- const cw = visualWidth(ch);
18
- if (w + cw > cols)
19
- break;
20
- out += ch;
21
- w += cw;
22
- i += ch.length;
23
- }
24
- return out + '\x1b[0m';
5
+ return clipAnsiToVisualWidth(line, cols) + '\x1b[0m';
25
6
  }
26
7
  export function fitLine(line, cols) {
27
8
  const clipped = visualWidth(line) > cols ? clipToWidth(line, cols) : line;
package/dist/app/keys.js CHANGED
@@ -1,3 +1,6 @@
1
+ function switchResult(target) {
2
+ return target === undefined ? { handled: true } : { switchTo: target, handled: true };
3
+ }
1
4
  export function routeGlobalKey(key, viewIds, current) {
2
5
  if (key === 'q' || key === '\x03')
3
6
  return { quit: true, handled: true };
@@ -5,7 +8,12 @@ export function routeGlobalKey(key, viewIds, current) {
5
8
  return current === 'home' ? { quit: true, handled: true } : { back: true, handled: true };
6
9
  if (key === '\t') {
7
10
  const i = viewIds.indexOf(current);
8
- return { switchTo: viewIds[(i + 1) % viewIds.length], handled: true };
11
+ return switchResult(viewIds[(i + 1) % viewIds.length]);
12
+ }
13
+ if (key === '\x1b[Z') {
14
+ const i = viewIds.indexOf(current);
15
+ const previous = i < 0 ? viewIds.length - 1 : (i - 1 + viewIds.length) % viewIds.length;
16
+ return switchResult(viewIds[previous]);
9
17
  }
10
18
  if (key === '\x1b[5~')
11
19
  return { scrollBy: -1, handled: true };
@@ -14,7 +22,7 @@ export function routeGlobalKey(key, viewIds, current) {
14
22
  if (/^[1-9]$/.test(key)) {
15
23
  const idx = Number(key) - 1;
16
24
  if (idx < viewIds.length)
17
- return { switchTo: viewIds[idx], handled: true };
25
+ return switchResult(viewIds[idx]);
18
26
  }
19
27
  return { handled: false };
20
28
  }
@@ -1,32 +1,34 @@
1
1
  import { type, space } from '../../core/theme.js';
2
2
  import { t } from '../../i18n/index.js';
3
3
  import { renderListFieldWithContext } from '../fields/list-field.js';
4
- import { visualWidth, wrapAnsiToVisualWidth } from '../../core/text.js';
4
+ import { visualWidth, wrapAnsiToVisualWidth, wrapAnsiWithIndent } from '../../core/text.js';
5
5
  function hintLines(label, cols) {
6
- const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY;
7
- const styled = type.hint(label);
8
- const preferredIndent = visualWidth(space.indent) < width ? space.indent : '';
9
- const indent = preferredIndent
10
- && visualWidth(styled) > width - visualWidth(preferredIndent)
11
- && visualWidth(styled) <= width
12
- ? ''
13
- : preferredIndent;
14
- const contentWidth = Math.max(1, width - visualWidth(indent));
15
- return wrapAnsiToVisualWidth(styled, contentWidth).map((line) => `${indent}${line}`);
6
+ return wrapAnsiWithIndent(type.hint(label), cols, space.indent);
16
7
  }
17
8
  function renderReader(lines, cols) {
18
9
  const contentWidth = Math.max(1, Math.min(80, cols - visualWidth(space.indent)));
19
- return lines.flatMap((line) => (wrapAnsiToVisualWidth(line, contentWidth).map((part) => `${space.indent}${part}`)));
10
+ return lines.flatMap((line) => wrapAnsiToVisualWidth(line, contentWidth).map((part) => `${space.indent}${part}`));
20
11
  }
21
12
  function listFieldForState(state) {
22
13
  switch (state.mode) {
23
- case 'sections': return state.sectionsField;
24
- case 'files': return state.filesField;
25
- case 'archivedGroups': return state.archivedGroupsField;
26
- case 'archivedFiles': return state.archivedFilesField;
27
- case 'searchResults': return state.searchResultsField;
28
- case 'reader': return state.readerLinksField;
29
- default: return undefined;
14
+ case 'sections':
15
+ return state.sectionsField;
16
+ case 'files':
17
+ return state.filesField;
18
+ case 'archivedGroups':
19
+ return state.archivedGroupsField;
20
+ case 'archivedFiles':
21
+ return state.archivedFilesField;
22
+ case 'searchResults':
23
+ return state.searchResultsField;
24
+ case 'reader':
25
+ return state.readerLinksField;
26
+ case 'error':
27
+ case 'loading':
28
+ case 'readerLoading':
29
+ case 'search':
30
+ case 'searchLoading':
31
+ return undefined;
30
32
  }
31
33
  }
32
34
  export function renderDocs(state, cols = 80, bodyRows = Number.POSITIVE_INFINITY) {
@@ -51,10 +53,17 @@ export function renderDocs(state, cols = 80, bodyRows = Number.POSITIVE_INFINITY
51
53
  case 'search':
52
54
  lines = state.searchField?.render(cols) ?? [];
53
55
  break;
56
+ case 'searchLoading':
57
+ lines = hintLines(trans.docs.searching, cols);
58
+ break;
54
59
  case 'searchResults':
55
- lines = state.searchResultsField ? renderListFieldWithContext([
56
- ...(state.searchResultsEmpty ? [...hintLines(trans.docs.searchNoResults, cols), ''] : []),
57
- ], state.searchResultsField, bodyRows, cols) : [];
60
+ lines = state.searchResultsField
61
+ ? renderListFieldWithContext([
62
+ ...(state.searchResultsEmpty
63
+ ? [...hintLines(trans.docs.searchNoResults, cols), '']
64
+ : []),
65
+ ], state.searchResultsField, bodyRows, cols)
66
+ : [];
58
67
  break;
59
68
  case 'readerLoading':
60
69
  lines = hintLines(trans.docs.loadingFile, cols);
@@ -66,8 +75,6 @@ export function renderDocs(state, cols = 80, bodyRows = Number.POSITIVE_INFINITY
66
75
  break;
67
76
  case 'error':
68
77
  return hintLines(state.errorMessage ?? trans.docs.loadError, cols);
69
- default:
70
- lines = [];
71
78
  }
72
79
  if (!state.errorMessage)
73
80
  return lines;
@@ -5,7 +5,8 @@ import { renderDocs } from './docs-render.js';
5
5
  import { setVimKeysActive } from '../../core/vim-keys.js';
6
6
  import { pickIcon } from '../../core/icons.js';
7
7
  import { getCurrentLanguage, t } from '../../i18n/index.js';
8
- import { localizeDocSections, fetchSections, fetchAllDocs, getArchivedGroups, cleanFileName, displayDocTitle, loadDocForReader, openDocsInBrowser, clearDocsCache, } from '../../features/docs.js';
8
+ import { sanitizeTerminalLine, truncate } from '../../core/text.js';
9
+ import { localizeDocSections, fetchSections, fetchDocMetadata, fetchSectionMetadata, searchDocuments, getArchivedGroups, displayDocTitle, loadDocForReader, openDocsInBrowser, clearDocsCache, } from '../../features/docs.js';
9
10
  let state = { mode: 'loading' };
10
11
  let sections = [];
11
12
  let archivedGroups = new Map();
@@ -14,19 +15,34 @@ 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
+ const DOC_HINT_WIDTH = 44;
27
27
  function backLabel() {
28
28
  return t().common.back;
29
29
  }
30
+ function optionalHint(hint) {
31
+ return hint === undefined ? {} : { hint };
32
+ }
33
+ function docHint(value) {
34
+ return value ? truncate(value, DOC_HINT_WIDTH) : undefined;
35
+ }
36
+ function withoutReaderLinksField(value) {
37
+ const next = { ...value };
38
+ delete next.readerLinksField;
39
+ return next;
40
+ }
41
+ function withoutErrorMessage(value) {
42
+ const next = { ...value };
43
+ delete next.errorMessage;
44
+ return next;
45
+ }
30
46
  function buildSectionsField() {
31
47
  const trans = t();
32
48
  const options = [
@@ -39,19 +55,16 @@ function buildSectionsField() {
39
55
  }
40
56
  function buildFilesField(section, maxVisible, initialIndex = 0) {
41
57
  const trans = t();
42
- const isIndex = (f) => f.name === 'index.md' || f.name.startsWith('index.');
43
- // nbtca/documents' repair/ and concepts/ sections are explicitly built
44
- // "hub + inline-link + search, no full sidebar" (.vitepress/config.mts) --
45
- // each has a hand-curated index.md landing page (concepts/index.md groups
46
- // all 21 entries by topic with one-line definitions; nothing like that
47
- // exists in a flat alphabetical list). It used to be filtered out
48
- // entirely here, making it unreachable from the Docs tab -- now it's
49
- // pinned to the top as a distinctly-labeled entry instead.
58
+ const isIndex = (file) => file.name === 'index.md' || file.name.startsWith('index.');
50
59
  const index = section.files.find(isIndex);
51
60
  const files = section.files.filter((f) => !isIndex(f));
52
61
  const options = [
53
62
  ...(index ? [{ value: index.path, label: trans.docs.overviewLabel }] : []),
54
- ...files.map((f) => ({ value: f.path, label: displayDocTitle(f.path, f.name) })),
63
+ ...files.map((file) => ({
64
+ value: file.path,
65
+ label: displayDocTitle(file.name, file.title),
66
+ ...optionalHint(docHint(file.summary)),
67
+ })),
55
68
  { value: '__back__', label: backLabel() },
56
69
  ];
57
70
  return new ListField({ title: section.label, options, maxVisible, initialIndex });
@@ -70,7 +83,7 @@ function buildArchivedGroupsField(groups, maxVisible, initialIndex = 0) {
70
83
  return a.localeCompare(b);
71
84
  });
72
85
  const options = [
73
- ...sortedKeys.map((k) => ({ value: k, label: k, hint: String(groups.get(k).length) })),
86
+ ...sortedKeys.map((k) => ({ value: k, label: k, hint: String(groups.get(k)?.length ?? 0) })),
74
87
  { value: '__back__', label: backLabel() },
75
88
  ];
76
89
  return new ListField({ title: trans.docs.categoryArchived, options, maxVisible, initialIndex });
@@ -81,11 +94,22 @@ function buildArchivedFilesField(groupKey, groupFiles, maxVisible, initialIndex
81
94
  const options = [
82
95
  ...groupFiles.map((f) => {
83
96
  const sub = f.path.split('/').slice(2, -1).join('/');
84
- return { value: f.path, label: cleanFileName(f.name), hint: subDirs.size > 1 ? sub : undefined };
97
+ return {
98
+ value: f.path,
99
+ label: displayDocTitle(f.name, f.title),
100
+ ...optionalHint(docHint(subDirs.size > 1
101
+ ? [sanitizeTerminalLine(sub), f.summary].filter(Boolean).join(' · ')
102
+ : f.summary)),
103
+ };
85
104
  }),
86
105
  { value: '__back__', label: backLabel() },
87
106
  ];
88
- return new ListField({ title: `${trans.docs.categoryArchived} · ${groupKey}`, options, maxVisible, initialIndex });
107
+ return new ListField({
108
+ title: `${trans.docs.categoryArchived} · ${groupKey}`,
109
+ options,
110
+ maxVisible,
111
+ initialIndex,
112
+ });
89
113
  }
90
114
  function buildReaderLinksField(links, maxVisible, initialIndex = 0) {
91
115
  const trans = t();
@@ -100,8 +124,12 @@ function buildSearchResultsField(matches, maxVisible, initialIndex = 0) {
100
124
  const options = [
101
125
  ...matches.map((result) => ({
102
126
  value: result.path,
103
- label: displayDocTitle(result.path, result.name),
104
- hint: result.path.includes('/') ? result.path.split('/').slice(0, -1).join('/') : undefined,
127
+ label: displayDocTitle(result.name, result.title),
128
+ ...optionalHint(docHint(result.excerpt ||
129
+ result.summary ||
130
+ (result.path.includes('/')
131
+ ? sanitizeTerminalLine(result.path.split('/').slice(0, -1).join('/'))
132
+ : undefined))),
105
133
  })),
106
134
  { value: '__back__', label: backLabel() },
107
135
  ];
@@ -114,7 +142,10 @@ function relocalizeStateFields(value, maxVisible) {
114
142
  if (value.mode === 'files' && currentSectionKey) {
115
143
  const section = sections.find((candidate) => candidate.key === currentSectionKey);
116
144
  return section
117
- ? { ...value, filesField: buildFilesField(section, maxVisible, value.filesField?.selectedIndex) }
145
+ ? {
146
+ ...value,
147
+ filesField: buildFilesField(section, maxVisible, value.filesField?.selectedIndex),
148
+ }
118
149
  : value;
119
150
  }
120
151
  if (value.mode === 'archivedGroups') {
@@ -149,10 +180,95 @@ function goToSections() {
149
180
  currentSearchResults = [];
150
181
  state = { mode: 'sections', sectionsField: buildSectionsField() };
151
182
  }
152
- /** Enters (or re-enters) the reader on `path`. `pushCurrent` distinguishes
153
- * following a link forward (push readerCurrentPath so Esc can return to it)
154
- * from navigating backward or entering fresh from a file list (nothing to
155
- * push -- the caller has already saved/cleared readerPrevState itself). */
183
+ function replaceSection(section) {
184
+ sections = sections.map((current) => (current.key === section.key ? section : current));
185
+ }
186
+ async function openSectionFiles(ctx, section) {
187
+ const requestId = ++metadataRequestId;
188
+ currentSectionKey = section.key;
189
+ state = {
190
+ mode: 'files',
191
+ filesField: buildFilesField(section, computeMaxVisible(ctx.bodyRows)),
192
+ };
193
+ ctx.rerender();
194
+ try {
195
+ const hydrated = await fetchSectionMetadata(section);
196
+ if (requestId !== metadataRequestId ||
197
+ state.mode !== 'files' ||
198
+ currentSectionKey !== section.key)
199
+ return;
200
+ const localized = localizeDocSections([hydrated], t())[0] ?? hydrated;
201
+ replaceSection(localized);
202
+ state = {
203
+ mode: 'files',
204
+ filesField: buildFilesField(localized, computeMaxVisible(ctx.bodyRows), state.filesField?.selectedIndex),
205
+ };
206
+ }
207
+ catch {
208
+ if (requestId !== metadataRequestId ||
209
+ state.mode !== 'files' ||
210
+ currentSectionKey !== section.key)
211
+ return;
212
+ state = { ...state, errorMessage: t().docs.loadError };
213
+ }
214
+ ctx.rerender();
215
+ }
216
+ async function openArchivedFiles(ctx, groupKey, groupFiles) {
217
+ const requestId = ++metadataRequestId;
218
+ currentArchivedGroupKey = groupKey;
219
+ state = {
220
+ mode: 'archivedFiles',
221
+ archivedFilesField: buildArchivedFilesField(groupKey, groupFiles, computeMaxVisible(ctx.bodyRows)),
222
+ };
223
+ ctx.rerender();
224
+ try {
225
+ const hydrated = await fetchDocMetadata(groupFiles);
226
+ if (requestId !== metadataRequestId ||
227
+ state.mode !== 'archivedFiles' ||
228
+ currentArchivedGroupKey !== groupKey)
229
+ return;
230
+ archivedGroups.set(groupKey, hydrated);
231
+ state = {
232
+ mode: 'archivedFiles',
233
+ archivedFilesField: buildArchivedFilesField(groupKey, hydrated, computeMaxVisible(ctx.bodyRows), state.archivedFilesField?.selectedIndex),
234
+ };
235
+ }
236
+ catch {
237
+ if (requestId !== metadataRequestId ||
238
+ state.mode !== 'archivedFiles' ||
239
+ currentArchivedGroupKey !== groupKey)
240
+ return;
241
+ state = {
242
+ ...state,
243
+ errorMessage: t().docs.loadError,
244
+ };
245
+ }
246
+ ctx.rerender();
247
+ }
248
+ async function runSearch(ctx, query) {
249
+ const requestId = ++searchRequestId;
250
+ state = { mode: 'searchLoading' };
251
+ ctx.rerender();
252
+ try {
253
+ const matches = await searchDocuments(query);
254
+ if (requestId !== searchRequestId)
255
+ return;
256
+ currentSearchResults = matches;
257
+ state = {
258
+ mode: 'searchResults',
259
+ searchResultsEmpty: matches.length === 0,
260
+ searchResultsField: buildSearchResultsField(matches, computeMaxVisible(ctx.bodyRows)),
261
+ };
262
+ }
263
+ catch {
264
+ if (requestId !== searchRequestId)
265
+ return;
266
+ currentSearchResults = [];
267
+ goToSections();
268
+ state = { ...state, errorMessage: t().docs.loadError };
269
+ }
270
+ ctx.rerender();
271
+ }
156
272
  async function openInReader(ctx, path, pushCurrent) {
157
273
  const requestId = ++readerRequestId;
158
274
  const previousState = state;
@@ -168,7 +284,12 @@ async function openInReader(ctx, path, pushCurrent) {
168
284
  readerNavStack.push(previousPath);
169
285
  readerCurrentPath = path;
170
286
  readerLoadingPrevState = null;
171
- state = { mode: 'reader', readerTitle: doc.title, readerLines: doc.lines, readerLinks: doc.links };
287
+ state = {
288
+ mode: 'reader',
289
+ readerTitle: doc.title,
290
+ readerLines: doc.lines,
291
+ readerLinks: doc.links,
292
+ };
172
293
  ctx.resetScroll();
173
294
  }
174
295
  catch {
@@ -176,15 +297,12 @@ async function openInReader(ctx, path, pushCurrent) {
176
297
  return;
177
298
  readerLoadingPrevState = null;
178
299
  const fallbackState = pushCurrent && previousState.mode === 'reader'
179
- ? { ...previousState, readerLinksField: undefined }
300
+ ? withoutReaderLinksField(previousState)
180
301
  : previousState;
181
302
  state = { ...fallbackState, errorMessage: t().docs.loadError };
182
303
  }
183
304
  ctx.rerender();
184
305
  }
185
- /** Enters the reader from a file-listing mode (files/archivedFiles/
186
- * searchResults) -- saves that listing so Esc can restore it once the nav
187
- * stack (built by following links from here) empties back out. */
188
306
  function enterReaderFrom(ctx, path) {
189
307
  readerPrevState = state;
190
308
  readerNavStack = [];
@@ -208,23 +326,26 @@ export const docsView = {
208
326
  }
209
327
  return;
210
328
  }
329
+ const requestId = ++sectionsRequestId;
211
330
  state = { mode: 'loading' };
212
331
  ctx.rerender();
213
332
  try {
214
- sections = await fetchSections();
333
+ const nextSections = await fetchSections();
334
+ if (requestId !== sectionsRequestId)
335
+ return;
336
+ sections = nextSections;
215
337
  loaded = true;
216
338
  loadedLanguage = getCurrentLanguage();
217
339
  goToSections();
218
340
  }
219
341
  catch {
342
+ if (requestId !== sectionsRequestId)
343
+ return;
220
344
  state = { mode: 'error', errorMessage: t().docs.loadError };
221
345
  }
222
346
  ctx.rerender();
223
347
  },
224
348
  render(ctx) {
225
- // Sync every visible field's scroll window to the *current* terminal
226
- // size on every frame (not just construction time) — this is what
227
- // keeps a long list correctly windowed across a live resize.
228
349
  const maxVisible = computeMaxVisible(ctx.bodyRows);
229
350
  state.filesField?.setMaxVisible(maxVisible);
230
351
  state.archivedGroupsField?.setMaxVisible(maxVisible);
@@ -236,10 +357,18 @@ export const docsView = {
236
357
  capturesInput() {
237
358
  return state.mode === 'search';
238
359
  },
360
+ capturesPageKeys() {
361
+ return (state.mode === 'sections' ||
362
+ state.mode === 'files' ||
363
+ state.mode === 'archivedGroups' ||
364
+ state.mode === 'archivedFiles' ||
365
+ state.mode === 'searchResults' ||
366
+ (state.mode === 'reader' && state.readerLinksField !== undefined));
367
+ },
239
368
  footerHint(tabCount, cols = Number.POSITIVE_INFINITY) {
240
369
  if (state.mode === 'search')
241
370
  return captureFooterHint(cols);
242
- if (state.mode === 'loading' || state.mode === 'error')
371
+ if (state.mode === 'loading' || state.mode === 'searchLoading' || state.mode === 'error')
243
372
  return passiveFooterHint(tabCount, cols);
244
373
  if (state.mode === 'readerLoading') {
245
374
  return fitFooterHint(cols, `${digitTabHint(tabCount)}q ${t().menu.hintQuit}`, `${digitTabHint(tabCount)}q`, 'q');
@@ -257,6 +386,11 @@ export const docsView = {
257
386
  return undefined;
258
387
  },
259
388
  handleBack(ctx) {
389
+ if (state.mode === 'searchLoading') {
390
+ searchRequestId++;
391
+ goToSections();
392
+ return true;
393
+ }
260
394
  if (state.mode === 'reader' || state.mode === 'readerLoading') {
261
395
  if (state.mode === 'readerLoading') {
262
396
  const previousState = readerLoadingPrevState;
@@ -268,7 +402,7 @@ export const docsView = {
268
402
  return true;
269
403
  }
270
404
  if (state.readerLinksField) {
271
- state = { ...state, readerLinksField: undefined };
405
+ state = withoutReaderLinksField(state);
272
406
  return true;
273
407
  }
274
408
  const prevPath = readerNavStack.pop();
@@ -285,7 +419,10 @@ export const docsView = {
285
419
  return false;
286
420
  }
287
421
  if (state.mode === 'archivedFiles') {
288
- state = { mode: 'archivedGroups', archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)) };
422
+ state = {
423
+ mode: 'archivedGroups',
424
+ archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)),
425
+ };
289
426
  return true;
290
427
  }
291
428
  if (state.mode === 'search') {
@@ -293,7 +430,9 @@ export const docsView = {
293
430
  goToSections();
294
431
  return true;
295
432
  }
296
- if (state.mode === 'files' || state.mode === 'archivedGroups' || state.mode === 'searchResults') {
433
+ if (state.mode === 'files' ||
434
+ state.mode === 'archivedGroups' ||
435
+ state.mode === 'searchResults') {
297
436
  goToSections();
298
437
  return true;
299
438
  }
@@ -301,7 +440,7 @@ export const docsView = {
301
440
  },
302
441
  handleKey(key, ctx) {
303
442
  if (state.mode !== 'error' && state.errorMessage)
304
- state = { ...state, errorMessage: undefined };
443
+ state = withoutErrorMessage(state);
305
444
  switch (state.mode) {
306
445
  case 'sections': {
307
446
  const result = state.sectionsField?.handleKey(key);
@@ -309,11 +448,21 @@ export const docsView = {
309
448
  return;
310
449
  if (result.selected === '__search__') {
311
450
  setVimKeysActive(false);
312
- state = { mode: 'search', searchField: new TextField({ message: t().docs.searchPrompt, placeholder: t().docs.searchPlaceholder, allowEmpty: true }) };
451
+ state = {
452
+ mode: 'search',
453
+ searchField: new TextField({
454
+ message: t().docs.searchPrompt,
455
+ placeholder: t().docs.searchPlaceholder,
456
+ allowEmpty: true,
457
+ }),
458
+ };
313
459
  return;
314
460
  }
315
461
  if (result.selected === '__refresh__') {
316
462
  clearDocsCache();
463
+ sectionsRequestId++;
464
+ metadataRequestId++;
465
+ searchRequestId++;
317
466
  loaded = false;
318
467
  loadedLanguage = null;
319
468
  sections = [];
@@ -326,7 +475,7 @@ export const docsView = {
326
475
  readerPrevState = null;
327
476
  readerLoadingPrevState = null;
328
477
  readerRequestId++;
329
- void docsView.load?.(ctx);
478
+ void docsView.load(ctx);
330
479
  return;
331
480
  }
332
481
  if (result.selected === '__browser__') {
@@ -337,14 +486,17 @@ export const docsView = {
337
486
  if (!section)
338
487
  return;
339
488
  if (section.key === 'archived') {
489
+ metadataRequestId++;
340
490
  currentSectionKey = null;
341
491
  currentArchivedGroupKey = null;
342
492
  archivedGroups = getArchivedGroups(section.files);
343
- state = { mode: 'archivedGroups', archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)) };
493
+ state = {
494
+ mode: 'archivedGroups',
495
+ archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)),
496
+ };
344
497
  }
345
498
  else {
346
- currentSectionKey = section.key;
347
- state = { mode: 'files', filesField: buildFilesField(section, computeMaxVisible(ctx.bodyRows)) };
499
+ void openSectionFiles(ctx, section);
348
500
  }
349
501
  return;
350
502
  }
@@ -367,9 +519,8 @@ export const docsView = {
367
519
  goToSections();
368
520
  return;
369
521
  }
370
- currentArchivedGroupKey = result.selected;
371
522
  const groupFiles = archivedGroups.get(result.selected) ?? [];
372
- state = { mode: 'archivedFiles', archivedFilesField: buildArchivedFilesField(result.selected, groupFiles, computeMaxVisible(ctx.bodyRows)) };
523
+ void openArchivedFiles(ctx, result.selected, groupFiles);
373
524
  return;
374
525
  }
375
526
  case 'archivedFiles': {
@@ -378,7 +529,10 @@ export const docsView = {
378
529
  return;
379
530
  if (result.selected === '__back__') {
380
531
  currentArchivedGroupKey = null;
381
- state = { mode: 'archivedGroups', archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)) };
532
+ state = {
533
+ mode: 'archivedGroups',
534
+ archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)),
535
+ };
382
536
  return;
383
537
  }
384
538
  enterReaderFrom(ctx, result.selected);
@@ -398,19 +552,7 @@ export const docsView = {
398
552
  goToSections();
399
553
  return;
400
554
  }
401
- void fetchAllDocs().then((all) => {
402
- const matches = all.filter((item) => item.path.toLowerCase().includes(query));
403
- currentSearchResults = matches;
404
- state = {
405
- mode: 'searchResults',
406
- searchResultsEmpty: matches.length === 0,
407
- searchResultsField: buildSearchResultsField(matches, computeMaxVisible(ctx.bodyRows)),
408
- };
409
- ctx.rerender();
410
- }).catch(() => {
411
- state = { mode: 'error', errorMessage: t().docs.loadError };
412
- ctx.rerender();
413
- });
555
+ void runSearch(ctx, query);
414
556
  }
415
557
  return;
416
558
  }
@@ -429,19 +571,18 @@ export const docsView = {
429
571
  if (state.readerLinksField) {
430
572
  const result = state.readerLinksField.handleKey(key);
431
573
  if (result.cancelled || result.selected === '__back__') {
432
- state = { ...state, readerLinksField: undefined };
574
+ state = withoutReaderLinksField(state);
433
575
  return;
434
576
  }
435
577
  if (result.selected)
436
578
  void openInReader(ctx, result.selected, true);
437
579
  return;
438
580
  }
439
- // 'f' (Vimium/vim-browser-extension convention: "follow a link"),
440
- // not 'l' -- core/vim-keys.ts already reserves 'l' globally,
441
- // ranger-style, as an alias for Enter/confirm (vimActive defaults
442
- // to true), so a literal 'l' keypress never even reaches here.
443
581
  if (key === 'f' && (state.readerLinks?.length ?? 0) > 0) {
444
- state = { ...state, readerLinksField: buildReaderLinksField(state.readerLinks ?? [], computeMaxVisible(ctx.bodyRows)) };
582
+ state = {
583
+ ...state,
584
+ readerLinksField: buildReaderLinksField(state.readerLinks ?? [], computeMaxVisible(ctx.bodyRows)),
585
+ };
445
586
  return;
446
587
  }
447
588
  if (key === 'b') {
@@ -450,7 +591,10 @@ export const docsView = {
450
591
  }
451
592
  return;
452
593
  }
453
- default:
594
+ case 'error':
595
+ case 'loading':
596
+ case 'readerLoading':
597
+ case 'searchLoading':
454
598
  return;
455
599
  }
456
600
  },