@nbtca/prompt 1.4.2 → 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 (67) hide show
  1. package/README.md +27 -58
  2. package/SECURITY.md +16 -45
  3. package/dist/app/app.js +53 -55
  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 +2 -21
  8. package/dist/app/keys.js +10 -2
  9. package/dist/app/views/docs-render.js +31 -24
  10. package/dist/app/views/docs.js +211 -60
  11. package/dist/app/views/events-render.js +19 -26
  12. package/dist/app/views/events.js +44 -31
  13. package/dist/app/views/home.js +28 -30
  14. package/dist/app/views/schedule-grid-cursor.js +9 -18
  15. package/dist/app/views/schedule-render.js +47 -71
  16. package/dist/app/views/schedule.js +158 -81
  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/config/data.js +9 -11
  24. package/dist/config/preferences.js +14 -7
  25. package/dist/core/calendar-day.js +37 -0
  26. package/dist/core/capabilities.js +6 -3
  27. package/dist/core/components/confirm.js +9 -8
  28. package/dist/core/components/menu.js +41 -16
  29. package/dist/core/components/messages.js +12 -4
  30. package/dist/core/components/painter.js +3 -1
  31. package/dist/core/components/spinner.js +17 -6
  32. package/dist/core/components/text-input.js +24 -18
  33. package/dist/core/icons.js +2 -2
  34. package/dist/core/logo.js +23 -5
  35. package/dist/core/motion.js +25 -19
  36. package/dist/core/text.js +182 -69
  37. package/dist/core/theme.js +0 -28
  38. package/dist/core/transitions.js +2 -2
  39. package/dist/core/ui.js +15 -13
  40. package/dist/core/vim-keys.js +9 -15
  41. package/dist/features/about.js +23 -0
  42. package/dist/features/calendar-heatmap.js +16 -40
  43. package/dist/features/calendar-query.js +1 -2
  44. package/dist/features/calendar.js +12 -185
  45. package/dist/features/docs.js +436 -275
  46. package/dist/features/schedule-render.js +65 -101
  47. package/dist/features/schedule-store.js +51 -9
  48. package/dist/features/schedule-view.js +46 -213
  49. package/dist/features/status.js +44 -56
  50. package/dist/features/student-timetable.js +73 -95
  51. package/dist/features/theme.js +6 -2
  52. package/dist/features/timetable-sanitize.js +40 -0
  53. package/dist/features/update.js +9 -27
  54. package/dist/i18n/index.js +83 -19
  55. package/dist/i18n/locales/en.json +1 -1
  56. package/dist/i18n/locales/zh.json +1 -1
  57. package/dist/index.js +83 -58
  58. package/dist/logo/ca-dotmatrix.txt +16 -18
  59. package/dist/main.js +7 -48
  60. package/package.json +27 -18
  61. package/bin/nbtca-welcome.js +0 -2
  62. package/dist/core/components/screen.js +0 -18
  63. package/dist/core/menu.js +0 -68
  64. package/dist/features/links.js +0 -36
  65. package/dist/features/schedule-query.js +0 -47
  66. package/dist/features/settings.js +0 -127
  67. package/dist/logo/ca-logo.png +0 -0
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,12 +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.');
58
+ const isIndex = (file) => file.name === 'index.md' || file.name.startsWith('index.');
43
59
  const index = section.files.find(isIndex);
44
60
  const files = section.files.filter((f) => !isIndex(f));
45
61
  const options = [
46
62
  ...(index ? [{ value: index.path, label: trans.docs.overviewLabel }] : []),
47
- ...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
+ })),
48
68
  { value: '__back__', label: backLabel() },
49
69
  ];
50
70
  return new ListField({ title: section.label, options, maxVisible, initialIndex });
@@ -63,7 +83,7 @@ function buildArchivedGroupsField(groups, maxVisible, initialIndex = 0) {
63
83
  return a.localeCompare(b);
64
84
  });
65
85
  const options = [
66
- ...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) })),
67
87
  { value: '__back__', label: backLabel() },
68
88
  ];
69
89
  return new ListField({ title: trans.docs.categoryArchived, options, maxVisible, initialIndex });
@@ -74,11 +94,22 @@ function buildArchivedFilesField(groupKey, groupFiles, maxVisible, initialIndex
74
94
  const options = [
75
95
  ...groupFiles.map((f) => {
76
96
  const sub = f.path.split('/').slice(2, -1).join('/');
77
- 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
+ };
78
104
  }),
79
105
  { value: '__back__', label: backLabel() },
80
106
  ];
81
- 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
+ });
82
113
  }
83
114
  function buildReaderLinksField(links, maxVisible, initialIndex = 0) {
84
115
  const trans = t();
@@ -93,8 +124,12 @@ function buildSearchResultsField(matches, maxVisible, initialIndex = 0) {
93
124
  const options = [
94
125
  ...matches.map((result) => ({
95
126
  value: result.path,
96
- label: displayDocTitle(result.path, result.name),
97
- 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))),
98
133
  })),
99
134
  { value: '__back__', label: backLabel() },
100
135
  ];
@@ -107,7 +142,10 @@ function relocalizeStateFields(value, maxVisible) {
107
142
  if (value.mode === 'files' && currentSectionKey) {
108
143
  const section = sections.find((candidate) => candidate.key === currentSectionKey);
109
144
  return section
110
- ? { ...value, filesField: buildFilesField(section, maxVisible, value.filesField?.selectedIndex) }
145
+ ? {
146
+ ...value,
147
+ filesField: buildFilesField(section, maxVisible, value.filesField?.selectedIndex),
148
+ }
111
149
  : value;
112
150
  }
113
151
  if (value.mode === 'archivedGroups') {
@@ -142,10 +180,95 @@ function goToSections() {
142
180
  currentSearchResults = [];
143
181
  state = { mode: 'sections', sectionsField: buildSectionsField() };
144
182
  }
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). */
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
+ }
149
272
  async function openInReader(ctx, path, pushCurrent) {
150
273
  const requestId = ++readerRequestId;
151
274
  const previousState = state;
@@ -161,7 +284,12 @@ async function openInReader(ctx, path, pushCurrent) {
161
284
  readerNavStack.push(previousPath);
162
285
  readerCurrentPath = path;
163
286
  readerLoadingPrevState = null;
164
- 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
+ };
165
293
  ctx.resetScroll();
166
294
  }
167
295
  catch {
@@ -169,15 +297,12 @@ async function openInReader(ctx, path, pushCurrent) {
169
297
  return;
170
298
  readerLoadingPrevState = null;
171
299
  const fallbackState = pushCurrent && previousState.mode === 'reader'
172
- ? { ...previousState, readerLinksField: undefined }
300
+ ? withoutReaderLinksField(previousState)
173
301
  : previousState;
174
302
  state = { ...fallbackState, errorMessage: t().docs.loadError };
175
303
  }
176
304
  ctx.rerender();
177
305
  }
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
306
  function enterReaderFrom(ctx, path) {
182
307
  readerPrevState = state;
183
308
  readerNavStack = [];
@@ -201,23 +326,26 @@ export const docsView = {
201
326
  }
202
327
  return;
203
328
  }
329
+ const requestId = ++sectionsRequestId;
204
330
  state = { mode: 'loading' };
205
331
  ctx.rerender();
206
332
  try {
207
- sections = await fetchSections();
333
+ const nextSections = await fetchSections();
334
+ if (requestId !== sectionsRequestId)
335
+ return;
336
+ sections = nextSections;
208
337
  loaded = true;
209
338
  loadedLanguage = getCurrentLanguage();
210
339
  goToSections();
211
340
  }
212
341
  catch {
342
+ if (requestId !== sectionsRequestId)
343
+ return;
213
344
  state = { mode: 'error', errorMessage: t().docs.loadError };
214
345
  }
215
346
  ctx.rerender();
216
347
  },
217
348
  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
349
  const maxVisible = computeMaxVisible(ctx.bodyRows);
222
350
  state.filesField?.setMaxVisible(maxVisible);
223
351
  state.archivedGroupsField?.setMaxVisible(maxVisible);
@@ -229,10 +357,18 @@ export const docsView = {
229
357
  capturesInput() {
230
358
  return state.mode === 'search';
231
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
+ },
232
368
  footerHint(tabCount, cols = Number.POSITIVE_INFINITY) {
233
369
  if (state.mode === 'search')
234
370
  return captureFooterHint(cols);
235
- if (state.mode === 'loading' || state.mode === 'error')
371
+ if (state.mode === 'loading' || state.mode === 'searchLoading' || state.mode === 'error')
236
372
  return passiveFooterHint(tabCount, cols);
237
373
  if (state.mode === 'readerLoading') {
238
374
  return fitFooterHint(cols, `${digitTabHint(tabCount)}q ${t().menu.hintQuit}`, `${digitTabHint(tabCount)}q`, 'q');
@@ -250,6 +386,11 @@ export const docsView = {
250
386
  return undefined;
251
387
  },
252
388
  handleBack(ctx) {
389
+ if (state.mode === 'searchLoading') {
390
+ searchRequestId++;
391
+ goToSections();
392
+ return true;
393
+ }
253
394
  if (state.mode === 'reader' || state.mode === 'readerLoading') {
254
395
  if (state.mode === 'readerLoading') {
255
396
  const previousState = readerLoadingPrevState;
@@ -261,7 +402,7 @@ export const docsView = {
261
402
  return true;
262
403
  }
263
404
  if (state.readerLinksField) {
264
- state = { ...state, readerLinksField: undefined };
405
+ state = withoutReaderLinksField(state);
265
406
  return true;
266
407
  }
267
408
  const prevPath = readerNavStack.pop();
@@ -278,7 +419,10 @@ export const docsView = {
278
419
  return false;
279
420
  }
280
421
  if (state.mode === 'archivedFiles') {
281
- state = { mode: 'archivedGroups', archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)) };
422
+ state = {
423
+ mode: 'archivedGroups',
424
+ archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)),
425
+ };
282
426
  return true;
283
427
  }
284
428
  if (state.mode === 'search') {
@@ -286,7 +430,9 @@ export const docsView = {
286
430
  goToSections();
287
431
  return true;
288
432
  }
289
- if (state.mode === 'files' || state.mode === 'archivedGroups' || state.mode === 'searchResults') {
433
+ if (state.mode === 'files' ||
434
+ state.mode === 'archivedGroups' ||
435
+ state.mode === 'searchResults') {
290
436
  goToSections();
291
437
  return true;
292
438
  }
@@ -294,7 +440,7 @@ export const docsView = {
294
440
  },
295
441
  handleKey(key, ctx) {
296
442
  if (state.mode !== 'error' && state.errorMessage)
297
- state = { ...state, errorMessage: undefined };
443
+ state = withoutErrorMessage(state);
298
444
  switch (state.mode) {
299
445
  case 'sections': {
300
446
  const result = state.sectionsField?.handleKey(key);
@@ -302,11 +448,21 @@ export const docsView = {
302
448
  return;
303
449
  if (result.selected === '__search__') {
304
450
  setVimKeysActive(false);
305
- 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
+ };
306
459
  return;
307
460
  }
308
461
  if (result.selected === '__refresh__') {
309
462
  clearDocsCache();
463
+ sectionsRequestId++;
464
+ metadataRequestId++;
465
+ searchRequestId++;
310
466
  loaded = false;
311
467
  loadedLanguage = null;
312
468
  sections = [];
@@ -319,7 +475,7 @@ export const docsView = {
319
475
  readerPrevState = null;
320
476
  readerLoadingPrevState = null;
321
477
  readerRequestId++;
322
- void docsView.load?.(ctx);
478
+ void docsView.load(ctx);
323
479
  return;
324
480
  }
325
481
  if (result.selected === '__browser__') {
@@ -330,14 +486,17 @@ export const docsView = {
330
486
  if (!section)
331
487
  return;
332
488
  if (section.key === 'archived') {
489
+ metadataRequestId++;
333
490
  currentSectionKey = null;
334
491
  currentArchivedGroupKey = null;
335
492
  archivedGroups = getArchivedGroups(section.files);
336
- state = { mode: 'archivedGroups', archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)) };
493
+ state = {
494
+ mode: 'archivedGroups',
495
+ archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)),
496
+ };
337
497
  }
338
498
  else {
339
- currentSectionKey = section.key;
340
- state = { mode: 'files', filesField: buildFilesField(section, computeMaxVisible(ctx.bodyRows)) };
499
+ void openSectionFiles(ctx, section);
341
500
  }
342
501
  return;
343
502
  }
@@ -360,9 +519,8 @@ export const docsView = {
360
519
  goToSections();
361
520
  return;
362
521
  }
363
- currentArchivedGroupKey = result.selected;
364
522
  const groupFiles = archivedGroups.get(result.selected) ?? [];
365
- state = { mode: 'archivedFiles', archivedFilesField: buildArchivedFilesField(result.selected, groupFiles, computeMaxVisible(ctx.bodyRows)) };
523
+ void openArchivedFiles(ctx, result.selected, groupFiles);
366
524
  return;
367
525
  }
368
526
  case 'archivedFiles': {
@@ -371,7 +529,10 @@ export const docsView = {
371
529
  return;
372
530
  if (result.selected === '__back__') {
373
531
  currentArchivedGroupKey = null;
374
- state = { mode: 'archivedGroups', archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)) };
532
+ state = {
533
+ mode: 'archivedGroups',
534
+ archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)),
535
+ };
375
536
  return;
376
537
  }
377
538
  enterReaderFrom(ctx, result.selected);
@@ -391,19 +552,7 @@ export const docsView = {
391
552
  goToSections();
392
553
  return;
393
554
  }
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
- });
555
+ void runSearch(ctx, query);
407
556
  }
408
557
  return;
409
558
  }
@@ -422,19 +571,18 @@ export const docsView = {
422
571
  if (state.readerLinksField) {
423
572
  const result = state.readerLinksField.handleKey(key);
424
573
  if (result.cancelled || result.selected === '__back__') {
425
- state = { ...state, readerLinksField: undefined };
574
+ state = withoutReaderLinksField(state);
426
575
  return;
427
576
  }
428
577
  if (result.selected)
429
578
  void openInReader(ctx, result.selected, true);
430
579
  return;
431
580
  }
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
581
  if (key === 'f' && (state.readerLinks?.length ?? 0) > 0) {
437
- state = { ...state, readerLinksField: buildReaderLinksField(state.readerLinks ?? [], computeMaxVisible(ctx.bodyRows)) };
582
+ state = {
583
+ ...state,
584
+ readerLinksField: buildReaderLinksField(state.readerLinks ?? [], computeMaxVisible(ctx.bodyRows)),
585
+ };
438
586
  return;
439
587
  }
440
588
  if (key === 'b') {
@@ -443,7 +591,10 @@ export const docsView = {
443
591
  }
444
592
  return;
445
593
  }
446
- default:
594
+ case 'error':
595
+ case 'loading':
596
+ case 'readerLoading':
597
+ case 'searchLoading':
447
598
  return;
448
599
  }
449
600
  },