@nbtca/prompt 1.3.2 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +45 -1
- package/SECURITY.md +47 -0
- package/dist/app/app.js +202 -0
- package/dist/app/chrome.js +104 -0
- package/dist/app/fields/list-field.js +174 -0
- package/dist/app/fields/text-field.js +38 -0
- package/dist/app/frame.js +48 -0
- package/dist/app/keys.js +20 -0
- package/dist/app/tabs.js +11 -0
- package/dist/app/view.js +1 -0
- package/dist/app/views/docs-render.js +82 -0
- package/dist/app/views/docs.js +450 -0
- package/dist/app/views/events-render.js +111 -0
- package/dist/app/views/events.js +228 -0
- package/dist/app/views/home.js +195 -0
- package/dist/app/views/schedule-grid-cursor.js +52 -0
- package/dist/app/views/schedule-render.js +317 -0
- package/dist/app/views/schedule.js +463 -0
- package/dist/app/views/settings-render.js +53 -0
- package/dist/app/views/settings.js +153 -0
- package/dist/auth/cookie-transport.js +222 -0
- package/dist/auth/errors.js +18 -0
- package/dist/auth/nbt-auth.js +239 -0
- package/dist/auth/session-store.js +118 -0
- package/dist/config/data.js +1 -2
- package/dist/config/paths.js +22 -2
- package/dist/core/canvas.js +23 -0
- package/dist/core/capabilities.js +42 -0
- package/dist/core/components/confirm.js +75 -0
- package/dist/core/components/input-session.js +24 -0
- package/dist/core/components/menu.js +122 -0
- package/dist/core/components/messages.js +16 -0
- package/dist/core/components/note.js +18 -0
- package/dist/core/components/painter.js +26 -0
- package/dist/core/components/screen.js +18 -0
- package/dist/core/components/spinner.js +47 -0
- package/dist/core/components/text-input.js +98 -0
- package/dist/core/logo.js +33 -22
- package/dist/core/menu.js +24 -9
- package/dist/core/motion.js +86 -0
- package/dist/core/text.js +121 -5
- package/dist/core/theme.js +61 -0
- package/dist/core/transitions.js +19 -0
- package/dist/core/ui.js +4 -45
- package/dist/features/calendar-heatmap.js +29 -27
- package/dist/features/calendar-query.js +50 -0
- package/dist/features/calendar.js +192 -98
- package/dist/features/docs.js +222 -61
- package/dist/features/links.js +7 -7
- package/dist/features/schedule-query.js +47 -0
- package/dist/features/schedule-render.js +573 -0
- package/dist/features/schedule-store.js +73 -0
- package/dist/features/schedule-view.js +253 -0
- package/dist/features/settings.js +43 -35
- package/dist/features/status.js +37 -16
- package/dist/features/student-timetable.js +346 -0
- package/dist/features/theme.js +0 -3
- package/dist/features/update.js +16 -18
- package/dist/i18n/index.js +5 -47
- package/dist/i18n/locales/en.json +149 -6
- package/dist/i18n/locales/zh.json +149 -6
- package/dist/index.js +61 -11
- package/dist/logo/ca-dotmatrix-large.txt +26 -0
- package/dist/logo/ca-dotmatrix-small.txt +12 -0
- package/dist/logo/ca-dotmatrix.txt +18 -16
- package/dist/logo/ca-logo.png +0 -0
- package/dist/main.js +33 -13
- package/package.json +18 -12
package/dist/app/keys.js
ADDED
|
@@ -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
|
+
}
|
package/dist/app/tabs.js
ADDED
|
@@ -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
|
+
}
|
package/dist/app/view.js
ADDED
|
@@ -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,450 @@
|
|
|
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
|
+
const index = section.files.find(isIndex);
|
|
44
|
+
const files = section.files.filter((f) => !isIndex(f));
|
|
45
|
+
const options = [
|
|
46
|
+
...(index ? [{ value: index.path, label: trans.docs.overviewLabel }] : []),
|
|
47
|
+
...files.map((f) => ({ value: f.path, label: displayDocTitle(f.path, f.name) })),
|
|
48
|
+
{ value: '__back__', label: backLabel() },
|
|
49
|
+
];
|
|
50
|
+
return new ListField({ title: section.label, options, maxVisible, initialIndex });
|
|
51
|
+
}
|
|
52
|
+
function buildArchivedGroupsField(groups, maxVisible, initialIndex = 0) {
|
|
53
|
+
const trans = t();
|
|
54
|
+
const sortedKeys = [...groups.keys()].sort((a, b) => {
|
|
55
|
+
const aYear = /^\d{4}$/.test(a);
|
|
56
|
+
const bYear = /^\d{4}$/.test(b);
|
|
57
|
+
if (aYear && bYear)
|
|
58
|
+
return Number(b) - Number(a);
|
|
59
|
+
if (aYear)
|
|
60
|
+
return -1;
|
|
61
|
+
if (bYear)
|
|
62
|
+
return 1;
|
|
63
|
+
return a.localeCompare(b);
|
|
64
|
+
});
|
|
65
|
+
const options = [
|
|
66
|
+
...sortedKeys.map((k) => ({ value: k, label: k, hint: String(groups.get(k).length) })),
|
|
67
|
+
{ value: '__back__', label: backLabel() },
|
|
68
|
+
];
|
|
69
|
+
return new ListField({ title: trans.docs.categoryArchived, options, maxVisible, initialIndex });
|
|
70
|
+
}
|
|
71
|
+
function buildArchivedFilesField(groupKey, groupFiles, maxVisible, initialIndex = 0) {
|
|
72
|
+
const trans = t();
|
|
73
|
+
const subDirs = new Set(groupFiles.map((f) => f.path.split('/')[2]).filter(Boolean));
|
|
74
|
+
const options = [
|
|
75
|
+
...groupFiles.map((f) => {
|
|
76
|
+
const sub = f.path.split('/').slice(2, -1).join('/');
|
|
77
|
+
return { value: f.path, label: cleanFileName(f.name), hint: subDirs.size > 1 ? sub : undefined };
|
|
78
|
+
}),
|
|
79
|
+
{ value: '__back__', label: backLabel() },
|
|
80
|
+
];
|
|
81
|
+
return new ListField({ title: `${trans.docs.categoryArchived} · ${groupKey}`, options, maxVisible, initialIndex });
|
|
82
|
+
}
|
|
83
|
+
function buildReaderLinksField(links, maxVisible, initialIndex = 0) {
|
|
84
|
+
const trans = t();
|
|
85
|
+
const options = [
|
|
86
|
+
...links.map((l) => ({ value: l.href, label: l.text })),
|
|
87
|
+
{ value: '__back__', label: backLabel() },
|
|
88
|
+
];
|
|
89
|
+
return new ListField({ title: trans.docs.readerLinksTitle, options, maxVisible, initialIndex });
|
|
90
|
+
}
|
|
91
|
+
function buildSearchResultsField(matches, maxVisible, initialIndex = 0) {
|
|
92
|
+
const trans = t();
|
|
93
|
+
const options = [
|
|
94
|
+
...matches.map((result) => ({
|
|
95
|
+
value: result.path,
|
|
96
|
+
label: displayDocTitle(result.path, result.name),
|
|
97
|
+
hint: result.path.includes('/') ? result.path.split('/').slice(0, -1).join('/') : undefined,
|
|
98
|
+
})),
|
|
99
|
+
{ value: '__back__', label: backLabel() },
|
|
100
|
+
];
|
|
101
|
+
return new ListField({ title: trans.docs.chooseDoc, options, maxVisible, initialIndex });
|
|
102
|
+
}
|
|
103
|
+
function relocalizeStateFields(value, maxVisible) {
|
|
104
|
+
if (value.mode === 'sections') {
|
|
105
|
+
return { ...value, sectionsField: buildSectionsField() };
|
|
106
|
+
}
|
|
107
|
+
if (value.mode === 'files' && currentSectionKey) {
|
|
108
|
+
const section = sections.find((candidate) => candidate.key === currentSectionKey);
|
|
109
|
+
return section
|
|
110
|
+
? { ...value, filesField: buildFilesField(section, maxVisible, value.filesField?.selectedIndex) }
|
|
111
|
+
: value;
|
|
112
|
+
}
|
|
113
|
+
if (value.mode === 'archivedGroups') {
|
|
114
|
+
return {
|
|
115
|
+
...value,
|
|
116
|
+
archivedGroupsField: buildArchivedGroupsField(archivedGroups, maxVisible, value.archivedGroupsField?.selectedIndex),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (value.mode === 'archivedFiles' && currentArchivedGroupKey) {
|
|
120
|
+
return {
|
|
121
|
+
...value,
|
|
122
|
+
archivedFilesField: buildArchivedFilesField(currentArchivedGroupKey, archivedGroups.get(currentArchivedGroupKey) ?? [], maxVisible, value.archivedFilesField?.selectedIndex),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (value.mode === 'searchResults') {
|
|
126
|
+
return {
|
|
127
|
+
...value,
|
|
128
|
+
searchResultsField: buildSearchResultsField(currentSearchResults, maxVisible, value.searchResultsField?.selectedIndex),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (value.mode === 'reader' && value.readerLinksField) {
|
|
132
|
+
return {
|
|
133
|
+
...value,
|
|
134
|
+
readerLinksField: buildReaderLinksField(value.readerLinks ?? [], maxVisible, value.readerLinksField.selectedIndex),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
function goToSections() {
|
|
140
|
+
currentSectionKey = null;
|
|
141
|
+
currentArchivedGroupKey = null;
|
|
142
|
+
currentSearchResults = [];
|
|
143
|
+
state = { mode: 'sections', sectionsField: buildSectionsField() };
|
|
144
|
+
}
|
|
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). */
|
|
149
|
+
async function openInReader(ctx, path, pushCurrent) {
|
|
150
|
+
const requestId = ++readerRequestId;
|
|
151
|
+
const previousState = state;
|
|
152
|
+
const previousPath = readerCurrentPath;
|
|
153
|
+
readerLoadingPrevState = previousState;
|
|
154
|
+
state = { mode: 'readerLoading' };
|
|
155
|
+
ctx.rerender();
|
|
156
|
+
try {
|
|
157
|
+
const doc = await loadDocForReader(path);
|
|
158
|
+
if (requestId !== readerRequestId)
|
|
159
|
+
return;
|
|
160
|
+
if (pushCurrent && previousPath)
|
|
161
|
+
readerNavStack.push(previousPath);
|
|
162
|
+
readerCurrentPath = path;
|
|
163
|
+
readerLoadingPrevState = null;
|
|
164
|
+
state = { mode: 'reader', readerTitle: doc.title, readerLines: doc.lines, readerLinks: doc.links };
|
|
165
|
+
ctx.resetScroll();
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
if (requestId !== readerRequestId)
|
|
169
|
+
return;
|
|
170
|
+
readerLoadingPrevState = null;
|
|
171
|
+
const fallbackState = pushCurrent && previousState.mode === 'reader'
|
|
172
|
+
? { ...previousState, readerLinksField: undefined }
|
|
173
|
+
: previousState;
|
|
174
|
+
state = { ...fallbackState, errorMessage: t().docs.loadError };
|
|
175
|
+
}
|
|
176
|
+
ctx.rerender();
|
|
177
|
+
}
|
|
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
|
+
function enterReaderFrom(ctx, path) {
|
|
182
|
+
readerPrevState = state;
|
|
183
|
+
readerNavStack = [];
|
|
184
|
+
readerCurrentPath = null;
|
|
185
|
+
void openInReader(ctx, path, false);
|
|
186
|
+
}
|
|
187
|
+
export const docsView = {
|
|
188
|
+
id: 'docs',
|
|
189
|
+
title: t().menu.docs,
|
|
190
|
+
async load(ctx) {
|
|
191
|
+
if (loaded) {
|
|
192
|
+
const language = getCurrentLanguage();
|
|
193
|
+
if (loadedLanguage !== language) {
|
|
194
|
+
sections = localizeDocSections(sections, t());
|
|
195
|
+
loadedLanguage = language;
|
|
196
|
+
const maxVisible = computeMaxVisible(ctx.bodyRows);
|
|
197
|
+
state = relocalizeStateFields(state, maxVisible);
|
|
198
|
+
if (readerPrevState)
|
|
199
|
+
readerPrevState = relocalizeStateFields(readerPrevState, maxVisible);
|
|
200
|
+
ctx.rerender();
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
state = { mode: 'loading' };
|
|
205
|
+
ctx.rerender();
|
|
206
|
+
try {
|
|
207
|
+
sections = await fetchSections();
|
|
208
|
+
loaded = true;
|
|
209
|
+
loadedLanguage = getCurrentLanguage();
|
|
210
|
+
goToSections();
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
state = { mode: 'error', errorMessage: t().docs.loadError };
|
|
214
|
+
}
|
|
215
|
+
ctx.rerender();
|
|
216
|
+
},
|
|
217
|
+
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
|
+
const maxVisible = computeMaxVisible(ctx.bodyRows);
|
|
222
|
+
state.filesField?.setMaxVisible(maxVisible);
|
|
223
|
+
state.archivedGroupsField?.setMaxVisible(maxVisible);
|
|
224
|
+
state.archivedFilesField?.setMaxVisible(maxVisible);
|
|
225
|
+
state.searchResultsField?.setMaxVisible(maxVisible);
|
|
226
|
+
state.readerLinksField?.setMaxVisible(maxVisible);
|
|
227
|
+
return renderDocs(state, ctx.size.cols, ctx.bodyRows);
|
|
228
|
+
},
|
|
229
|
+
capturesInput() {
|
|
230
|
+
return state.mode === 'search';
|
|
231
|
+
},
|
|
232
|
+
footerHint(tabCount, cols = Number.POSITIVE_INFINITY) {
|
|
233
|
+
if (state.mode === 'search')
|
|
234
|
+
return captureFooterHint(cols);
|
|
235
|
+
if (state.mode === 'loading' || state.mode === 'error')
|
|
236
|
+
return passiveFooterHint(tabCount, cols);
|
|
237
|
+
if (state.mode === 'readerLoading') {
|
|
238
|
+
return fitFooterHint(cols, `${digitTabHint(tabCount)}q ${t().menu.hintQuit}`, `${digitTabHint(tabCount)}q`, 'q');
|
|
239
|
+
}
|
|
240
|
+
if (state.mode === 'reader' && !state.readerLinksField) {
|
|
241
|
+
const trans = t();
|
|
242
|
+
const dot = pickIcon('·', '-');
|
|
243
|
+
const hasLinks = (state.readerLinks?.length ?? 0) > 0;
|
|
244
|
+
const linkHint = hasLinks ? `f ${trans.docs.readerLinksHint} ${dot} ` : '';
|
|
245
|
+
const pageHint = `PgUp/PgDn ${dot} `;
|
|
246
|
+
const localFull = `${pageHint}${linkHint}b ${trans.docs.openBrowser} ${dot} Esc ${dot} q ${trans.menu.hintQuit}`;
|
|
247
|
+
const localCompact = `${pageHint}${hasLinks ? `f ${dot} ` : ''}b ${dot} Esc ${dot} q`;
|
|
248
|
+
return fitFooterHint(cols, `${digitTabHint(tabCount)}${localFull}`, localFull, localCompact, `${hasLinks ? 'f ' : ''}b Esc q`, 'Esc q', 'q');
|
|
249
|
+
}
|
|
250
|
+
return undefined;
|
|
251
|
+
},
|
|
252
|
+
handleBack(ctx) {
|
|
253
|
+
if (state.mode === 'reader' || state.mode === 'readerLoading') {
|
|
254
|
+
if (state.mode === 'readerLoading') {
|
|
255
|
+
const previousState = readerLoadingPrevState;
|
|
256
|
+
readerRequestId++;
|
|
257
|
+
readerLoadingPrevState = null;
|
|
258
|
+
if (!previousState)
|
|
259
|
+
return false;
|
|
260
|
+
state = previousState;
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
if (state.readerLinksField) {
|
|
264
|
+
state = { ...state, readerLinksField: undefined };
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
const prevPath = readerNavStack.pop();
|
|
268
|
+
if (prevPath) {
|
|
269
|
+
void openInReader(ctx, prevPath, false);
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
if (readerPrevState) {
|
|
273
|
+
state = readerPrevState;
|
|
274
|
+
readerPrevState = null;
|
|
275
|
+
readerCurrentPath = null;
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
if (state.mode === 'archivedFiles') {
|
|
281
|
+
state = { mode: 'archivedGroups', archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)) };
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
if (state.mode === 'search') {
|
|
285
|
+
setVimKeysActive(true);
|
|
286
|
+
goToSections();
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
if (state.mode === 'files' || state.mode === 'archivedGroups' || state.mode === 'searchResults') {
|
|
290
|
+
goToSections();
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
},
|
|
295
|
+
handleKey(key, ctx) {
|
|
296
|
+
if (state.mode !== 'error' && state.errorMessage)
|
|
297
|
+
state = { ...state, errorMessage: undefined };
|
|
298
|
+
switch (state.mode) {
|
|
299
|
+
case 'sections': {
|
|
300
|
+
const result = state.sectionsField?.handleKey(key);
|
|
301
|
+
if (!result?.selected)
|
|
302
|
+
return;
|
|
303
|
+
if (result.selected === '__search__') {
|
|
304
|
+
setVimKeysActive(false);
|
|
305
|
+
state = { mode: 'search', searchField: new TextField({ message: t().docs.searchPrompt, placeholder: t().docs.searchPlaceholder, allowEmpty: true }) };
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (result.selected === '__refresh__') {
|
|
309
|
+
clearDocsCache();
|
|
310
|
+
loaded = false;
|
|
311
|
+
loadedLanguage = null;
|
|
312
|
+
sections = [];
|
|
313
|
+
archivedGroups = new Map();
|
|
314
|
+
currentSectionKey = null;
|
|
315
|
+
currentArchivedGroupKey = null;
|
|
316
|
+
currentSearchResults = [];
|
|
317
|
+
readerCurrentPath = null;
|
|
318
|
+
readerNavStack = [];
|
|
319
|
+
readerPrevState = null;
|
|
320
|
+
readerLoadingPrevState = null;
|
|
321
|
+
readerRequestId++;
|
|
322
|
+
void docsView.load?.(ctx);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (result.selected === '__browser__') {
|
|
326
|
+
void ctx.runClassic(() => openDocsInBrowser());
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const section = sections.find((s) => s.key === result.selected);
|
|
330
|
+
if (!section)
|
|
331
|
+
return;
|
|
332
|
+
if (section.key === 'archived') {
|
|
333
|
+
currentSectionKey = null;
|
|
334
|
+
currentArchivedGroupKey = null;
|
|
335
|
+
archivedGroups = getArchivedGroups(section.files);
|
|
336
|
+
state = { mode: 'archivedGroups', archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)) };
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
currentSectionKey = section.key;
|
|
340
|
+
state = { mode: 'files', filesField: buildFilesField(section, computeMaxVisible(ctx.bodyRows)) };
|
|
341
|
+
}
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
case 'files': {
|
|
345
|
+
const result = state.filesField?.handleKey(key);
|
|
346
|
+
if (!result?.selected)
|
|
347
|
+
return;
|
|
348
|
+
if (result.selected === '__back__') {
|
|
349
|
+
goToSections();
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
enterReaderFrom(ctx, result.selected);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
case 'archivedGroups': {
|
|
356
|
+
const result = state.archivedGroupsField?.handleKey(key);
|
|
357
|
+
if (!result?.selected)
|
|
358
|
+
return;
|
|
359
|
+
if (result.selected === '__back__') {
|
|
360
|
+
goToSections();
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
currentArchivedGroupKey = result.selected;
|
|
364
|
+
const groupFiles = archivedGroups.get(result.selected) ?? [];
|
|
365
|
+
state = { mode: 'archivedFiles', archivedFilesField: buildArchivedFilesField(result.selected, groupFiles, computeMaxVisible(ctx.bodyRows)) };
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
case 'archivedFiles': {
|
|
369
|
+
const result = state.archivedFilesField?.handleKey(key);
|
|
370
|
+
if (!result?.selected)
|
|
371
|
+
return;
|
|
372
|
+
if (result.selected === '__back__') {
|
|
373
|
+
currentArchivedGroupKey = null;
|
|
374
|
+
state = { mode: 'archivedGroups', archivedGroupsField: buildArchivedGroupsField(archivedGroups, computeMaxVisible(ctx.bodyRows)) };
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
enterReaderFrom(ctx, result.selected);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
case 'search': {
|
|
381
|
+
const result = state.searchField?.handleKey(key);
|
|
382
|
+
if (result?.cancelled) {
|
|
383
|
+
setVimKeysActive(true);
|
|
384
|
+
goToSections();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (result?.submitted !== undefined) {
|
|
388
|
+
const query = result.submitted.trim().toLowerCase();
|
|
389
|
+
setVimKeysActive(true);
|
|
390
|
+
if (!query) {
|
|
391
|
+
goToSections();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
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
|
+
});
|
|
407
|
+
}
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
case 'searchResults': {
|
|
411
|
+
const result = state.searchResultsField?.handleKey(key);
|
|
412
|
+
if (!result?.selected)
|
|
413
|
+
return;
|
|
414
|
+
if (result.selected === '__back__') {
|
|
415
|
+
goToSections();
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
enterReaderFrom(ctx, result.selected);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
case 'reader': {
|
|
422
|
+
if (state.readerLinksField) {
|
|
423
|
+
const result = state.readerLinksField.handleKey(key);
|
|
424
|
+
if (result.cancelled || result.selected === '__back__') {
|
|
425
|
+
state = { ...state, readerLinksField: undefined };
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (result.selected)
|
|
429
|
+
void openInReader(ctx, result.selected, true);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
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
|
+
if (key === 'f' && (state.readerLinks?.length ?? 0) > 0) {
|
|
437
|
+
state = { ...state, readerLinksField: buildReaderLinksField(state.readerLinks ?? [], computeMaxVisible(ctx.bodyRows)) };
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (key === 'b') {
|
|
441
|
+
void ctx.runClassic(() => openDocsInBrowser(readerCurrentPath ?? undefined));
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
default:
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
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 { renderCountdownBanner, renderEventBrief } from '../../features/calendar.js';
|
|
5
|
+
import { renderHeatmap } from '../../features/calendar-heatmap.js';
|
|
6
|
+
import { visualWidth, wrapAnsiToVisualWidth } from '../../core/text.js';
|
|
7
|
+
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}`);
|
|
19
|
+
}
|
|
20
|
+
function wrappedRenderedLine(line, cols) {
|
|
21
|
+
const content = line.startsWith(space.indent) ? line.slice(space.indent.length) : line;
|
|
22
|
+
return wrappedIndentedLines(content, cols, (value) => value);
|
|
23
|
+
}
|
|
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
|
+
const EXPANDED_HUB_MIN_BODY_ROWS = 29;
|
|
30
|
+
function renderHubBody(state, now, bodyRows, cols) {
|
|
31
|
+
const trans = t();
|
|
32
|
+
const lines = [];
|
|
33
|
+
const rows = Number.isFinite(bodyRows)
|
|
34
|
+
? Math.max(0, Math.floor(bodyRows))
|
|
35
|
+
: Number.POSITIVE_INFINITY;
|
|
36
|
+
const banner = renderCountdownBanner(state.nextEvent, now, cols);
|
|
37
|
+
if (banner)
|
|
38
|
+
lines.push(...banner.split('\n'), '');
|
|
39
|
+
const buckets = state.heatmapBuckets;
|
|
40
|
+
if (bodyRows >= EXPANDED_HUB_MIN_BODY_ROWS && buckets && buckets.length > 0) {
|
|
41
|
+
lines.push(...renderHeatmap(buckets, now, { color: true, cols }).split('\n'));
|
|
42
|
+
lines.push('');
|
|
43
|
+
}
|
|
44
|
+
if (state.recentEvents && state.recentEvents.length > 0) {
|
|
45
|
+
const activityHeading = wrappedIndentedLines(trans.calendar.recentActivity, cols, type.heading);
|
|
46
|
+
const fieldRows = state.hubField
|
|
47
|
+
? state.hubField.render(Number.POSITIVE_INFINITY, cols).length
|
|
48
|
+
: 0;
|
|
49
|
+
const collectEventLines = (reservedFieldRows) => {
|
|
50
|
+
const budget = Math.max(0, rows - lines.length - activityHeading.length - 1 - reservedFieldRows);
|
|
51
|
+
const collected = [];
|
|
52
|
+
for (const event of state.recentEvents ?? []) {
|
|
53
|
+
const wrapped = wrappedRenderedLine(renderEventBrief(event, now), cols);
|
|
54
|
+
if (collected.length + wrapped.length > budget)
|
|
55
|
+
break;
|
|
56
|
+
collected.push(...wrapped);
|
|
57
|
+
}
|
|
58
|
+
return collected;
|
|
59
|
+
};
|
|
60
|
+
let eventLines = collectEventLines(fieldRows);
|
|
61
|
+
if (eventLines.length === 0 && state.hubField && fieldRows > 3) {
|
|
62
|
+
eventLines = collectEventLines(Math.min(3, rows));
|
|
63
|
+
}
|
|
64
|
+
if (eventLines.length > 0)
|
|
65
|
+
lines.push(...activityHeading, ...eventLines, '');
|
|
66
|
+
}
|
|
67
|
+
if (state.hubField) {
|
|
68
|
+
return renderListFieldWithContext(lines, state.hubField, bodyRows, cols);
|
|
69
|
+
}
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
export function renderEvents(state, now, bodyRows = 100, cols) {
|
|
73
|
+
const trans = t();
|
|
74
|
+
switch (state.mode) {
|
|
75
|
+
case 'loading':
|
|
76
|
+
return wrappedIndentedLines(trans.calendar.loading, cols, type.hint);
|
|
77
|
+
case 'hub':
|
|
78
|
+
return renderHubBody(state, now, bodyRows, cols);
|
|
79
|
+
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
|
+
return state.heatmapBuckets && state.heatmapBuckets.length > 0
|
|
85
|
+
? renderHeatmap(state.heatmapBuckets, now, { color: true, cols }).split('\n')
|
|
86
|
+
: wrappedIndentedLines(trans.calendar.noEvents, cols, type.hint);
|
|
87
|
+
case 'list':
|
|
88
|
+
return state.listField?.render(bodyRows, cols) ?? [];
|
|
89
|
+
case 'detail': {
|
|
90
|
+
const context = [
|
|
91
|
+
...wrappedIndentedLines(state.detailTitle ?? '', cols, type.heading),
|
|
92
|
+
...wrappedIndentedLines(state.detailMeta ?? '', cols, type.hint),
|
|
93
|
+
'',
|
|
94
|
+
...(state.detailDescription
|
|
95
|
+
? state.detailDescription.split('\n').flatMap((line) => wrappedIndentedLines(line, cols, type.body))
|
|
96
|
+
: wrappedIndentedLines(trans.calendar.noDescription, cols, type.hint)),
|
|
97
|
+
'',
|
|
98
|
+
...(state.statusMessage ? [...wrappedIndentedLines(state.statusMessage, cols, type.hint), ''] : []),
|
|
99
|
+
];
|
|
100
|
+
return state.detailField
|
|
101
|
+
? renderListFieldWithContext(context, state.detailField, bodyRows, cols)
|
|
102
|
+
: Number.isFinite(bodyRows) ? context.slice(0, Math.max(0, Math.floor(bodyRows))) : context;
|
|
103
|
+
}
|
|
104
|
+
case 'search':
|
|
105
|
+
return state.searchField?.render(cols) ?? [];
|
|
106
|
+
case 'error':
|
|
107
|
+
return wrappedIndentedLines(state.errorMessage ?? trans.calendar.error, cols, type.hint);
|
|
108
|
+
default:
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
}
|