@nbtca/prompt 1.3.2 → 1.4.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 (65) hide show
  1. package/README.md +44 -0
  2. package/SECURITY.md +47 -0
  3. package/dist/app/app.js +202 -0
  4. package/dist/app/chrome.js +104 -0
  5. package/dist/app/fields/list-field.js +174 -0
  6. package/dist/app/fields/text-field.js +38 -0
  7. package/dist/app/frame.js +48 -0
  8. package/dist/app/keys.js +20 -0
  9. package/dist/app/tabs.js +11 -0
  10. package/dist/app/view.js +1 -0
  11. package/dist/app/views/docs-render.js +82 -0
  12. package/dist/app/views/docs.js +457 -0
  13. package/dist/app/views/events-render.js +111 -0
  14. package/dist/app/views/events.js +228 -0
  15. package/dist/app/views/home.js +236 -0
  16. package/dist/app/views/schedule-grid-cursor.js +52 -0
  17. package/dist/app/views/schedule-render.js +317 -0
  18. package/dist/app/views/schedule.js +472 -0
  19. package/dist/app/views/settings-render.js +53 -0
  20. package/dist/app/views/settings.js +153 -0
  21. package/dist/auth/cookie-transport.js +222 -0
  22. package/dist/auth/errors.js +18 -0
  23. package/dist/auth/nbt-auth.js +239 -0
  24. package/dist/auth/session-store.js +118 -0
  25. package/dist/config/paths.js +22 -2
  26. package/dist/core/canvas.js +23 -0
  27. package/dist/core/capabilities.js +42 -0
  28. package/dist/core/components/confirm.js +75 -0
  29. package/dist/core/components/input-session.js +24 -0
  30. package/dist/core/components/menu.js +122 -0
  31. package/dist/core/components/messages.js +16 -0
  32. package/dist/core/components/note.js +18 -0
  33. package/dist/core/components/painter.js +26 -0
  34. package/dist/core/components/screen.js +18 -0
  35. package/dist/core/components/spinner.js +47 -0
  36. package/dist/core/components/text-input.js +98 -0
  37. package/dist/core/logo.js +40 -15
  38. package/dist/core/menu.js +24 -6
  39. package/dist/core/motion.js +86 -0
  40. package/dist/core/text.js +127 -5
  41. package/dist/core/theme.js +61 -0
  42. package/dist/core/transitions.js +19 -0
  43. package/dist/core/ui.js +5 -29
  44. package/dist/features/calendar-heatmap.js +29 -27
  45. package/dist/features/calendar-query.js +50 -0
  46. package/dist/features/calendar.js +192 -98
  47. package/dist/features/docs.js +258 -55
  48. package/dist/features/links.js +7 -4
  49. package/dist/features/schedule-query.js +47 -0
  50. package/dist/features/schedule-render.js +574 -0
  51. package/dist/features/schedule-store.js +73 -0
  52. package/dist/features/schedule-view.js +260 -0
  53. package/dist/features/settings.js +41 -30
  54. package/dist/features/status.js +37 -13
  55. package/dist/features/student-timetable.js +346 -0
  56. package/dist/features/update.js +16 -8
  57. package/dist/i18n/locales/en.json +149 -6
  58. package/dist/i18n/locales/zh.json +149 -6
  59. package/dist/index.js +59 -5
  60. package/dist/logo/ca-dotmatrix-large.txt +26 -0
  61. package/dist/logo/ca-dotmatrix-small.txt +12 -0
  62. package/dist/logo/ca-dotmatrix.txt +18 -16
  63. package/dist/logo/ca-logo.png +0 -0
  64. package/dist/main.js +33 -13
  65. package/package.json +10 -7
@@ -0,0 +1,20 @@
1
+ export function routeGlobalKey(key, viewIds, current) {
2
+ if (key === 'q' || key === '\x03')
3
+ return { quit: true, handled: true };
4
+ if (key === '\x1b')
5
+ return current === 'home' ? { quit: true, handled: true } : { back: true, handled: true };
6
+ if (key === '\t') {
7
+ const i = viewIds.indexOf(current);
8
+ return { switchTo: viewIds[(i + 1) % viewIds.length], handled: true };
9
+ }
10
+ if (key === '\x1b[5~')
11
+ return { scrollBy: -1, handled: true };
12
+ if (key === '\x1b[6~')
13
+ return { scrollBy: 1, handled: true };
14
+ if (/^[1-9]$/.test(key)) {
15
+ const idx = Number(key) - 1;
16
+ if (idx < viewIds.length)
17
+ return { switchTo: viewIds[idx], handled: true };
18
+ }
19
+ return { handled: false };
20
+ }
@@ -0,0 +1,11 @@
1
+ import { t } from '../i18n/index.js';
2
+ export function getAppTabs() {
3
+ const trans = t();
4
+ return [
5
+ { id: 'home', title: 'Home' },
6
+ { id: 'schedule', title: trans.timetable.menuEntry },
7
+ { id: 'events', title: trans.menu.events },
8
+ { id: 'docs', title: trans.menu.docs },
9
+ { id: 'settings', title: trans.menu.settings },
10
+ ];
11
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,82 @@
1
+ import { type, space } from '../../core/theme.js';
2
+ import { t } from '../../i18n/index.js';
3
+ import { renderListFieldWithContext } from '../fields/list-field.js';
4
+ import { visualWidth, wrapAnsiToVisualWidth } from '../../core/text.js';
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}`);
16
+ }
17
+ function renderReader(lines, cols) {
18
+ 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}`)));
20
+ }
21
+ function listFieldForState(state) {
22
+ 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;
30
+ }
31
+ }
32
+ export function renderDocs(state, cols = 80, bodyRows = Number.POSITIVE_INFINITY) {
33
+ const trans = t();
34
+ let lines;
35
+ switch (state.mode) {
36
+ case 'loading':
37
+ lines = hintLines(trans.common.loading, cols);
38
+ break;
39
+ case 'sections':
40
+ lines = state.sectionsField?.render(bodyRows, cols) ?? [];
41
+ break;
42
+ case 'files':
43
+ lines = state.filesField?.render(bodyRows, cols) ?? [];
44
+ break;
45
+ case 'archivedGroups':
46
+ lines = state.archivedGroupsField?.render(bodyRows, cols) ?? [];
47
+ break;
48
+ case 'archivedFiles':
49
+ lines = state.archivedFilesField?.render(bodyRows, cols) ?? [];
50
+ break;
51
+ case 'search':
52
+ lines = state.searchField?.render(cols) ?? [];
53
+ break;
54
+ case 'searchResults':
55
+ lines = state.searchResultsField ? renderListFieldWithContext([
56
+ ...(state.searchResultsEmpty ? [...hintLines(trans.docs.searchNoResults, cols), ''] : []),
57
+ ], state.searchResultsField, bodyRows, cols) : [];
58
+ break;
59
+ case 'readerLoading':
60
+ lines = hintLines(trans.docs.loadingFile, cols);
61
+ break;
62
+ case 'reader':
63
+ lines = state.readerLinksField
64
+ ? state.readerLinksField.render(bodyRows, cols)
65
+ : renderReader(state.readerLines ?? [], cols);
66
+ break;
67
+ case 'error':
68
+ return hintLines(state.errorMessage ?? trans.docs.loadError, cols);
69
+ default:
70
+ lines = [];
71
+ }
72
+ if (!state.errorMessage)
73
+ return lines;
74
+ const errorContext = [...hintLines(state.errorMessage, cols), ''];
75
+ const listField = listFieldForState(state);
76
+ if (!listField)
77
+ return [...errorContext, ...lines];
78
+ const context = state.mode === 'searchResults' && state.searchResultsEmpty
79
+ ? [...errorContext, ...hintLines(trans.docs.searchNoResults, cols), '']
80
+ : errorContext;
81
+ return renderListFieldWithContext(context, listField, bodyRows, cols);
82
+ }
@@ -0,0 +1,457 @@
1
+ import { captureFooterHint, digitTabHint, fitFooterHint, passiveFooterHint } from '../chrome.js';
2
+ import { ListField, computeMaxVisible } from '../fields/list-field.js';
3
+ import { TextField } from '../fields/text-field.js';
4
+ import { renderDocs } from './docs-render.js';
5
+ import { setVimKeysActive } from '../../core/vim-keys.js';
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';
9
+ let state = { mode: 'loading' };
10
+ let sections = [];
11
+ let archivedGroups = new Map();
12
+ let loaded = false;
13
+ let loadedLanguage = null;
14
+ let currentSectionKey = null;
15
+ let currentArchivedGroupKey = null;
16
+ 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.
22
+ let readerCurrentPath = null;
23
+ let readerNavStack = [];
24
+ let readerPrevState = null;
25
+ let readerLoadingPrevState = null;
26
+ let readerRequestId = 0;
27
+ function backLabel() {
28
+ return t().common.back;
29
+ }
30
+ function buildSectionsField() {
31
+ const trans = t();
32
+ const options = [
33
+ ...sections.map((sec) => ({ value: sec.key, label: sec.label, hint: String(sec.count) })),
34
+ { value: '__search__', label: trans.docs.searchPrompt.replace(':', '') },
35
+ { value: '__refresh__', label: trans.docs.refreshCache },
36
+ { value: '__browser__', label: trans.docs.openBrowser },
37
+ ];
38
+ return new ListField({ title: trans.docs.chooseCategory, options });
39
+ }
40
+ function buildFilesField(section, maxVisible, initialIndex = 0) {
41
+ 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.
50
+ const index = section.files.find(isIndex);
51
+ const files = section.files.filter((f) => !isIndex(f));
52
+ const options = [
53
+ ...(index ? [{ value: index.path, label: trans.docs.overviewLabel }] : []),
54
+ ...files.map((f) => ({ value: f.path, label: displayDocTitle(f.path, f.name) })),
55
+ { value: '__back__', label: backLabel() },
56
+ ];
57
+ return new ListField({ title: section.label, options, maxVisible, initialIndex });
58
+ }
59
+ function buildArchivedGroupsField(groups, maxVisible, initialIndex = 0) {
60
+ const trans = t();
61
+ const sortedKeys = [...groups.keys()].sort((a, b) => {
62
+ const aYear = /^\d{4}$/.test(a);
63
+ const bYear = /^\d{4}$/.test(b);
64
+ if (aYear && bYear)
65
+ return Number(b) - Number(a);
66
+ if (aYear)
67
+ return -1;
68
+ if (bYear)
69
+ return 1;
70
+ return a.localeCompare(b);
71
+ });
72
+ const options = [
73
+ ...sortedKeys.map((k) => ({ value: k, label: k, hint: String(groups.get(k).length) })),
74
+ { value: '__back__', label: backLabel() },
75
+ ];
76
+ return new ListField({ title: trans.docs.categoryArchived, options, maxVisible, initialIndex });
77
+ }
78
+ function buildArchivedFilesField(groupKey, groupFiles, maxVisible, initialIndex = 0) {
79
+ const trans = t();
80
+ const subDirs = new Set(groupFiles.map((f) => f.path.split('/')[2]).filter(Boolean));
81
+ const options = [
82
+ ...groupFiles.map((f) => {
83
+ const sub = f.path.split('/').slice(2, -1).join('/');
84
+ return { value: f.path, label: cleanFileName(f.name), hint: subDirs.size > 1 ? sub : undefined };
85
+ }),
86
+ { value: '__back__', label: backLabel() },
87
+ ];
88
+ return new ListField({ title: `${trans.docs.categoryArchived} · ${groupKey}`, options, maxVisible, initialIndex });
89
+ }
90
+ function buildReaderLinksField(links, maxVisible, initialIndex = 0) {
91
+ const trans = t();
92
+ const options = [
93
+ ...links.map((l) => ({ value: l.href, label: l.text })),
94
+ { value: '__back__', label: backLabel() },
95
+ ];
96
+ return new ListField({ title: trans.docs.readerLinksTitle, options, maxVisible, initialIndex });
97
+ }
98
+ function buildSearchResultsField(matches, maxVisible, initialIndex = 0) {
99
+ const trans = t();
100
+ const options = [
101
+ ...matches.map((result) => ({
102
+ value: result.path,
103
+ label: displayDocTitle(result.path, result.name),
104
+ hint: result.path.includes('/') ? result.path.split('/').slice(0, -1).join('/') : undefined,
105
+ })),
106
+ { value: '__back__', label: backLabel() },
107
+ ];
108
+ return new ListField({ title: trans.docs.chooseDoc, options, maxVisible, initialIndex });
109
+ }
110
+ function relocalizeStateFields(value, maxVisible) {
111
+ if (value.mode === 'sections') {
112
+ return { ...value, sectionsField: buildSectionsField() };
113
+ }
114
+ if (value.mode === 'files' && currentSectionKey) {
115
+ const section = sections.find((candidate) => candidate.key === currentSectionKey);
116
+ return section
117
+ ? { ...value, filesField: buildFilesField(section, maxVisible, value.filesField?.selectedIndex) }
118
+ : value;
119
+ }
120
+ if (value.mode === 'archivedGroups') {
121
+ return {
122
+ ...value,
123
+ archivedGroupsField: buildArchivedGroupsField(archivedGroups, maxVisible, value.archivedGroupsField?.selectedIndex),
124
+ };
125
+ }
126
+ if (value.mode === 'archivedFiles' && currentArchivedGroupKey) {
127
+ return {
128
+ ...value,
129
+ archivedFilesField: buildArchivedFilesField(currentArchivedGroupKey, archivedGroups.get(currentArchivedGroupKey) ?? [], maxVisible, value.archivedFilesField?.selectedIndex),
130
+ };
131
+ }
132
+ if (value.mode === 'searchResults') {
133
+ return {
134
+ ...value,
135
+ searchResultsField: buildSearchResultsField(currentSearchResults, maxVisible, value.searchResultsField?.selectedIndex),
136
+ };
137
+ }
138
+ if (value.mode === 'reader' && value.readerLinksField) {
139
+ return {
140
+ ...value,
141
+ readerLinksField: buildReaderLinksField(value.readerLinks ?? [], maxVisible, value.readerLinksField.selectedIndex),
142
+ };
143
+ }
144
+ return value;
145
+ }
146
+ function goToSections() {
147
+ currentSectionKey = null;
148
+ currentArchivedGroupKey = null;
149
+ currentSearchResults = [];
150
+ state = { mode: 'sections', sectionsField: buildSectionsField() };
151
+ }
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). */
156
+ async function openInReader(ctx, path, pushCurrent) {
157
+ const requestId = ++readerRequestId;
158
+ const previousState = state;
159
+ const previousPath = readerCurrentPath;
160
+ readerLoadingPrevState = previousState;
161
+ state = { mode: 'readerLoading' };
162
+ ctx.rerender();
163
+ try {
164
+ const doc = await loadDocForReader(path);
165
+ if (requestId !== readerRequestId)
166
+ return;
167
+ if (pushCurrent && previousPath)
168
+ readerNavStack.push(previousPath);
169
+ readerCurrentPath = path;
170
+ readerLoadingPrevState = null;
171
+ state = { mode: 'reader', readerTitle: doc.title, readerLines: doc.lines, readerLinks: doc.links };
172
+ ctx.resetScroll();
173
+ }
174
+ catch {
175
+ if (requestId !== readerRequestId)
176
+ return;
177
+ readerLoadingPrevState = null;
178
+ const fallbackState = pushCurrent && previousState.mode === 'reader'
179
+ ? { ...previousState, readerLinksField: undefined }
180
+ : previousState;
181
+ state = { ...fallbackState, errorMessage: t().docs.loadError };
182
+ }
183
+ ctx.rerender();
184
+ }
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
+ function enterReaderFrom(ctx, path) {
189
+ readerPrevState = state;
190
+ readerNavStack = [];
191
+ readerCurrentPath = null;
192
+ void openInReader(ctx, path, false);
193
+ }
194
+ export const docsView = {
195
+ id: 'docs',
196
+ title: t().menu.docs,
197
+ async load(ctx) {
198
+ if (loaded) {
199
+ const language = getCurrentLanguage();
200
+ if (loadedLanguage !== language) {
201
+ sections = localizeDocSections(sections, t());
202
+ loadedLanguage = language;
203
+ const maxVisible = computeMaxVisible(ctx.bodyRows);
204
+ state = relocalizeStateFields(state, maxVisible);
205
+ if (readerPrevState)
206
+ readerPrevState = relocalizeStateFields(readerPrevState, maxVisible);
207
+ ctx.rerender();
208
+ }
209
+ return;
210
+ }
211
+ state = { mode: 'loading' };
212
+ ctx.rerender();
213
+ try {
214
+ sections = await fetchSections();
215
+ loaded = true;
216
+ loadedLanguage = getCurrentLanguage();
217
+ goToSections();
218
+ }
219
+ catch {
220
+ state = { mode: 'error', errorMessage: t().docs.loadError };
221
+ }
222
+ ctx.rerender();
223
+ },
224
+ 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
+ const maxVisible = computeMaxVisible(ctx.bodyRows);
229
+ state.filesField?.setMaxVisible(maxVisible);
230
+ state.archivedGroupsField?.setMaxVisible(maxVisible);
231
+ state.archivedFilesField?.setMaxVisible(maxVisible);
232
+ state.searchResultsField?.setMaxVisible(maxVisible);
233
+ state.readerLinksField?.setMaxVisible(maxVisible);
234
+ return renderDocs(state, ctx.size.cols, ctx.bodyRows);
235
+ },
236
+ capturesInput() {
237
+ return state.mode === 'search';
238
+ },
239
+ footerHint(tabCount, cols = Number.POSITIVE_INFINITY) {
240
+ if (state.mode === 'search')
241
+ return captureFooterHint(cols);
242
+ if (state.mode === 'loading' || state.mode === 'error')
243
+ return passiveFooterHint(tabCount, cols);
244
+ if (state.mode === 'readerLoading') {
245
+ return fitFooterHint(cols, `${digitTabHint(tabCount)}q ${t().menu.hintQuit}`, `${digitTabHint(tabCount)}q`, 'q');
246
+ }
247
+ if (state.mode === 'reader' && !state.readerLinksField) {
248
+ const trans = t();
249
+ const dot = pickIcon('·', '-');
250
+ const hasLinks = (state.readerLinks?.length ?? 0) > 0;
251
+ const linkHint = hasLinks ? `f ${trans.docs.readerLinksHint} ${dot} ` : '';
252
+ const pageHint = `PgUp/PgDn ${dot} `;
253
+ const localFull = `${pageHint}${linkHint}b ${trans.docs.openBrowser} ${dot} Esc ${dot} q ${trans.menu.hintQuit}`;
254
+ const localCompact = `${pageHint}${hasLinks ? `f ${dot} ` : ''}b ${dot} Esc ${dot} q`;
255
+ return fitFooterHint(cols, `${digitTabHint(tabCount)}${localFull}`, localFull, localCompact, `${hasLinks ? 'f ' : ''}b Esc q`, 'Esc q', 'q');
256
+ }
257
+ return undefined;
258
+ },
259
+ handleBack(ctx) {
260
+ if (state.mode === 'reader' || state.mode === 'readerLoading') {
261
+ if (state.mode === 'readerLoading') {
262
+ const previousState = readerLoadingPrevState;
263
+ readerRequestId++;
264
+ readerLoadingPrevState = null;
265
+ if (!previousState)
266
+ return false;
267
+ state = previousState;
268
+ return true;
269
+ }
270
+ if (state.readerLinksField) {
271
+ state = { ...state, readerLinksField: undefined };
272
+ return true;
273
+ }
274
+ const prevPath = readerNavStack.pop();
275
+ if (prevPath) {
276
+ void openInReader(ctx, prevPath, false);
277
+ return true;
278
+ }
279
+ if (readerPrevState) {
280
+ state = readerPrevState;
281
+ readerPrevState = null;
282
+ readerCurrentPath = null;
283
+ return true;
284
+ }
285
+ return false;
286
+ }
287
+ if (state.mode === 'archivedFiles') {
288
+ state = { mode: 'archivedGroups', archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)) };
289
+ return true;
290
+ }
291
+ if (state.mode === 'search') {
292
+ setVimKeysActive(true);
293
+ goToSections();
294
+ return true;
295
+ }
296
+ if (state.mode === 'files' || state.mode === 'archivedGroups' || state.mode === 'searchResults') {
297
+ goToSections();
298
+ return true;
299
+ }
300
+ return false;
301
+ },
302
+ handleKey(key, ctx) {
303
+ if (state.mode !== 'error' && state.errorMessage)
304
+ state = { ...state, errorMessage: undefined };
305
+ switch (state.mode) {
306
+ case 'sections': {
307
+ const result = state.sectionsField?.handleKey(key);
308
+ if (!result?.selected)
309
+ return;
310
+ if (result.selected === '__search__') {
311
+ setVimKeysActive(false);
312
+ state = { mode: 'search', searchField: new TextField({ message: t().docs.searchPrompt, placeholder: t().docs.searchPlaceholder, allowEmpty: true }) };
313
+ return;
314
+ }
315
+ if (result.selected === '__refresh__') {
316
+ clearDocsCache();
317
+ loaded = false;
318
+ loadedLanguage = null;
319
+ sections = [];
320
+ archivedGroups = new Map();
321
+ currentSectionKey = null;
322
+ currentArchivedGroupKey = null;
323
+ currentSearchResults = [];
324
+ readerCurrentPath = null;
325
+ readerNavStack = [];
326
+ readerPrevState = null;
327
+ readerLoadingPrevState = null;
328
+ readerRequestId++;
329
+ void docsView.load?.(ctx);
330
+ return;
331
+ }
332
+ if (result.selected === '__browser__') {
333
+ void ctx.runClassic(() => openDocsInBrowser());
334
+ return;
335
+ }
336
+ const section = sections.find((s) => s.key === result.selected);
337
+ if (!section)
338
+ return;
339
+ if (section.key === 'archived') {
340
+ currentSectionKey = null;
341
+ currentArchivedGroupKey = null;
342
+ archivedGroups = getArchivedGroups(section.files);
343
+ state = { mode: 'archivedGroups', archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)) };
344
+ }
345
+ else {
346
+ currentSectionKey = section.key;
347
+ state = { mode: 'files', filesField: buildFilesField(section, computeMaxVisible(ctx.bodyRows)) };
348
+ }
349
+ return;
350
+ }
351
+ case 'files': {
352
+ const result = state.filesField?.handleKey(key);
353
+ if (!result?.selected)
354
+ return;
355
+ if (result.selected === '__back__') {
356
+ goToSections();
357
+ return;
358
+ }
359
+ enterReaderFrom(ctx, result.selected);
360
+ return;
361
+ }
362
+ case 'archivedGroups': {
363
+ const result = state.archivedGroupsField?.handleKey(key);
364
+ if (!result?.selected)
365
+ return;
366
+ if (result.selected === '__back__') {
367
+ goToSections();
368
+ return;
369
+ }
370
+ currentArchivedGroupKey = result.selected;
371
+ const groupFiles = archivedGroups.get(result.selected) ?? [];
372
+ state = { mode: 'archivedFiles', archivedFilesField: buildArchivedFilesField(result.selected, groupFiles, computeMaxVisible(ctx.bodyRows)) };
373
+ return;
374
+ }
375
+ case 'archivedFiles': {
376
+ const result = state.archivedFilesField?.handleKey(key);
377
+ if (!result?.selected)
378
+ return;
379
+ if (result.selected === '__back__') {
380
+ currentArchivedGroupKey = null;
381
+ state = { mode: 'archivedGroups', archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)) };
382
+ return;
383
+ }
384
+ enterReaderFrom(ctx, result.selected);
385
+ return;
386
+ }
387
+ case 'search': {
388
+ const result = state.searchField?.handleKey(key);
389
+ if (result?.cancelled) {
390
+ setVimKeysActive(true);
391
+ goToSections();
392
+ return;
393
+ }
394
+ if (result?.submitted !== undefined) {
395
+ const query = result.submitted.trim().toLowerCase();
396
+ setVimKeysActive(true);
397
+ if (!query) {
398
+ goToSections();
399
+ return;
400
+ }
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
+ });
414
+ }
415
+ return;
416
+ }
417
+ case 'searchResults': {
418
+ const result = state.searchResultsField?.handleKey(key);
419
+ if (!result?.selected)
420
+ return;
421
+ if (result.selected === '__back__') {
422
+ goToSections();
423
+ return;
424
+ }
425
+ enterReaderFrom(ctx, result.selected);
426
+ return;
427
+ }
428
+ case 'reader': {
429
+ if (state.readerLinksField) {
430
+ const result = state.readerLinksField.handleKey(key);
431
+ if (result.cancelled || result.selected === '__back__') {
432
+ state = { ...state, readerLinksField: undefined };
433
+ return;
434
+ }
435
+ if (result.selected)
436
+ void openInReader(ctx, result.selected, true);
437
+ return;
438
+ }
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
+ if (key === 'f' && (state.readerLinks?.length ?? 0) > 0) {
444
+ state = { ...state, readerLinksField: buildReaderLinksField(state.readerLinks ?? [], computeMaxVisible(ctx.bodyRows)) };
445
+ return;
446
+ }
447
+ if (key === 'b') {
448
+ void ctx.runClassic(() => openDocsInBrowser(readerCurrentPath ?? undefined));
449
+ return;
450
+ }
451
+ return;
452
+ }
453
+ default:
454
+ return;
455
+ }
456
+ },
457
+ };