@nbtca/prompt 1.5.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ansi, ensureCursorRestored } from '../core/canvas.js';
2
- import { composeFrame, computeBodyRows } from './frame.js';
3
- import { routeGlobalKey } from './keys.js';
2
+ import { composeFrameLines, computeBodyRows, diffFrame } from './frame.js';
3
+ import { isPrintableKey, KeyStreamDecoder, routeGlobalKey } from './keys.js';
4
4
  import { renderHeader, renderFooter, resolveChromeLayout } from './chrome.js';
5
5
  import { homeView } from './views/home.js';
6
6
  import { scheduleView } from './views/schedule.js';
@@ -8,6 +8,7 @@ import { docsView } from './views/docs.js';
8
8
  import { eventsView } from './views/events.js';
9
9
  import { settingsView } from './views/settings.js';
10
10
  import { getAppTabs } from './tabs.js';
11
+ import { SPINNER_FRAME_MS } from '../core/components/spinner.js';
11
12
  export async function runApp() {
12
13
  if (!process.stdin.isTTY || !process.stdout.isTTY)
13
14
  return;
@@ -15,6 +16,13 @@ export async function runApp() {
15
16
  let scroll = 0;
16
17
  let running = true;
17
18
  let suspended = false;
19
+ let entered = false;
20
+ const lifecycle = new AbortController();
21
+ const keyDecoder = new KeyStreamDecoder();
22
+ let keyFlushTimer;
23
+ let clockTimer;
24
+ let busyTimer;
25
+ let painted;
18
26
  const viewIds = getAppTabs().map((tab) => tab.id);
19
27
  const nativeViews = {
20
28
  home: homeView,
@@ -38,6 +46,7 @@ export async function runApp() {
38
46
  return { rows: process.stdout.rows || 24, cols: process.stdout.columns || 80 };
39
47
  }
40
48
  const ctx = {
49
+ signal: lifecycle.signal,
41
50
  get size() {
42
51
  return size();
43
52
  },
@@ -70,11 +79,29 @@ export async function runApp() {
70
79
  const footer = renderFooter(view, cols, tabs.length, active?.footerHint?.(tabs.length, cols), chrome.footerLines);
71
80
  const body = active?.render(ctx) ?? [];
72
81
  const bodyScroll = active?.capturesInput?.() ? Number.MAX_SAFE_INTEGER : scroll;
73
- process.stdout.write(ansi.home + composeFrame(header, body, footer, rows, cols, bodyScroll) + ansi.eraseDown);
82
+ const lines = composeFrameLines(header, body, footer, rows, cols, bodyScroll);
83
+ const patch = diffFrame(painted?.cols === cols ? painted.lines : undefined, lines);
84
+ painted = { cols, lines };
85
+ if (patch)
86
+ process.stdout.write(patch);
87
+ scheduleBusyTick(active?.isBusy?.() === true);
74
88
  }
75
- function onKey(data) {
76
- const key = data.toString();
89
+ function scheduleBusyTick(busy) {
90
+ if (busy && running && busyTimer === undefined) {
91
+ busyTimer = setTimeout(() => {
92
+ busyTimer = undefined;
93
+ render();
94
+ }, SPINNER_FRAME_MS);
95
+ return;
96
+ }
97
+ if (busy || busyTimer === undefined)
98
+ return;
99
+ clearTimeout(busyTimer);
100
+ busyTimer = undefined;
101
+ }
102
+ function dispatchKey(key) {
77
103
  if (key === '\x03') {
104
+ process.exitCode = 130;
78
105
  quit();
79
106
  return;
80
107
  } // Ctrl-C always quits, even mid-capture.
@@ -119,15 +146,73 @@ export async function runApp() {
119
146
  active?.handleKey?.(key, ctx);
120
147
  render();
121
148
  }
149
+ function dispatchKeys(keys) {
150
+ for (let index = 0; index < keys.length && running && !suspended; index += 1) {
151
+ let key = keys[index];
152
+ if (key === undefined)
153
+ continue;
154
+ if (nativeViews[view]?.capturesInput?.() && isPrintableKey(key)) {
155
+ while (index + 1 < keys.length) {
156
+ const next = keys[index + 1];
157
+ if (next === undefined || !isPrintableKey(next))
158
+ break;
159
+ key += next;
160
+ index += 1;
161
+ }
162
+ }
163
+ dispatchKey(key);
164
+ }
165
+ }
166
+ function scheduleClock() {
167
+ if (!running)
168
+ return;
169
+ clockTimer = setTimeout(() => {
170
+ clockTimer = undefined;
171
+ render();
172
+ scheduleClock();
173
+ }, 60_000 - (Date.now() % 60_000));
174
+ }
175
+ function clearKeyFlush() {
176
+ if (keyFlushTimer === undefined)
177
+ return;
178
+ clearTimeout(keyFlushTimer);
179
+ keyFlushTimer = undefined;
180
+ }
181
+ function onKey(data) {
182
+ clearKeyFlush();
183
+ dispatchKeys(keyDecoder.write(data));
184
+ if (!running || suspended || !keyDecoder.hasPending)
185
+ return;
186
+ keyFlushTimer = setTimeout(() => {
187
+ keyFlushTimer = undefined;
188
+ dispatchKeys(keyDecoder.flush());
189
+ }, 20);
190
+ }
122
191
  function enter() {
192
+ if (entered || lifecycle.signal.aborted)
193
+ return;
194
+ entered = true;
195
+ painted = undefined;
196
+ keyDecoder.reset();
123
197
  ensureCursorRestored();
124
- process.stdout.write(ansi.enterAlt + ansi.hideCursor);
125
- if (process.stdin.isTTY)
126
- process.stdin.setRawMode(true);
127
- process.stdin.resume();
128
- process.stdin.on('data', onKey);
198
+ try {
199
+ process.stdout.write(ansi.enterAlt + ansi.hideCursor);
200
+ if (process.stdin.isTTY)
201
+ process.stdin.setRawMode(true);
202
+ process.stdin.resume();
203
+ process.stdin.on('data', onKey);
204
+ }
205
+ catch (error) {
206
+ leave();
207
+ throw error;
208
+ }
129
209
  }
130
210
  function leave() {
211
+ if (!entered)
212
+ return;
213
+ entered = false;
214
+ clearKeyFlush();
215
+ keyDecoder.reset();
131
216
  process.stdin.removeListener('data', onKey);
132
217
  if (process.stdin.isTTY)
133
218
  process.stdin.setRawMode(false);
@@ -162,6 +247,15 @@ export async function runApp() {
162
247
  leave();
163
248
  }
164
249
  function onSigint() {
250
+ process.exitCode = 130;
251
+ quit();
252
+ }
253
+ function onSigterm() {
254
+ process.exitCode = 143;
255
+ quit();
256
+ }
257
+ function onSighup() {
258
+ process.exitCode = 129;
165
259
  quit();
166
260
  }
167
261
  let resolveRun;
@@ -172,6 +266,12 @@ export async function runApp() {
172
266
  if (!running)
173
267
  return;
174
268
  running = false;
269
+ lifecycle.abort();
270
+ if (clockTimer !== undefined) {
271
+ clearTimeout(clockTimer);
272
+ clockTimer = undefined;
273
+ }
274
+ scheduleBusyTick(false);
175
275
  try {
176
276
  leave();
177
277
  }
@@ -179,16 +279,21 @@ export async function runApp() {
179
279
  process.stdout.removeListener('resize', onResize);
180
280
  process.removeListener('exit', onExit);
181
281
  process.removeListener('SIGINT', onSigint);
282
+ process.removeListener('SIGTERM', onSigterm);
283
+ process.removeListener('SIGHUP', onSighup);
182
284
  resolveRun();
183
285
  }
184
286
  }
185
287
  process.on('exit', onExit);
186
288
  process.stdout.on('resize', onResize);
187
289
  process.once('SIGINT', onSigint);
290
+ process.once('SIGTERM', onSigterm);
291
+ process.once('SIGHUP', onSighup);
188
292
  try {
189
293
  enter();
190
294
  loadView('home');
191
295
  render();
296
+ scheduleClock();
192
297
  await done;
193
298
  }
194
299
  finally {
package/dist/app/frame.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { ansi } from '../core/canvas.js';
1
2
  import { clipAnsiToVisualWidth, visualWidth } from '../core/text.js';
2
3
  export function clipToWidth(line, cols) {
3
4
  if (visualWidth(line) <= cols)
@@ -17,12 +18,23 @@ export function fitBody(lines, height, scroll, cols) {
17
18
  out.push(' '.repeat(cols));
18
19
  return out;
19
20
  }
20
- export function composeFrame(header, body, footer, rows, cols, scroll) {
21
+ export function composeFrameLines(header, body, footer, rows, cols, scroll) {
21
22
  const h = header.map((l) => fitLine(l, cols));
22
23
  const f = footer.map((l) => fitLine(l, cols));
23
24
  const bodyH = Math.max(0, rows - h.length - f.length);
24
25
  const b = fitBody(body, bodyH, scroll, cols);
25
- return [...h, ...b, ...f].slice(0, rows).join('\n');
26
+ return [...h, ...b, ...f].slice(0, rows);
27
+ }
28
+ export function diffFrame(prev, next) {
29
+ if (prev?.length !== next.length)
30
+ return ansi.home + next.join('\n') + ansi.eraseDown;
31
+ let out = '';
32
+ for (let row = 0; row < next.length; row += 1) {
33
+ const line = next[row];
34
+ if (line !== undefined && prev[row] !== line)
35
+ out += ansi.cursorToRow(row + 1) + line;
36
+ }
37
+ return out;
26
38
  }
27
39
  export function computeBodyRows(rows, headerLines, footerLines) {
28
40
  return Math.max(0, rows - headerLines - footerLines);
package/dist/app/keys.js CHANGED
@@ -1,3 +1,103 @@
1
+ const ESC = '\x1b';
2
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
3
+ function isControl(value) {
4
+ const code = value.charCodeAt(0);
5
+ return code <= 0x1f || (code >= 0x7f && code <= 0x9f);
6
+ }
7
+ export function isPrintableKey(value) {
8
+ if (value.length === 0 || value.startsWith(ESC))
9
+ return false;
10
+ return Array.from(value).every((char) => !isControl(char));
11
+ }
12
+ function escapeSequenceLength(value) {
13
+ if (value.length === 1)
14
+ return null;
15
+ const introducer = value[1];
16
+ if (introducer === ESC || (introducer !== undefined && isControl(introducer)))
17
+ return 1;
18
+ if (introducer === '[' || introducer === 'O') {
19
+ for (let index = 2; index < value.length; index += 1) {
20
+ const code = value.charCodeAt(index);
21
+ if (code >= 0x40 && code <= 0x7e)
22
+ return index + 1;
23
+ if (code < 0x20 || code > 0x3f)
24
+ return 1;
25
+ }
26
+ return null;
27
+ }
28
+ if (introducer === ']' || introducer === 'P' || introducer === '^' || introducer === '_') {
29
+ for (let index = 2; index < value.length; index += 1) {
30
+ if (value[index] === '\x07')
31
+ return index + 1;
32
+ if (value[index] === ESC && value[index + 1] === '\\')
33
+ return index + 2;
34
+ }
35
+ return null;
36
+ }
37
+ const segment = GRAPHEME_SEGMENTER.segment(value.slice(1))[Symbol.iterator]().next();
38
+ return segment.done ? null : 1 + segment.value.segment.length;
39
+ }
40
+ export class KeyStreamDecoder {
41
+ decoder = new TextDecoder();
42
+ pending = '';
43
+ get hasPending() {
44
+ return this.pending.length > 0;
45
+ }
46
+ write(data) {
47
+ this.pending += typeof data === 'string' ? data : this.decoder.decode(data, { stream: true });
48
+ return this.drain(false);
49
+ }
50
+ flush() {
51
+ return this.drain(true);
52
+ }
53
+ reset() {
54
+ this.pending = '';
55
+ this.decoder = new TextDecoder();
56
+ }
57
+ drain(flush) {
58
+ const keys = [];
59
+ let offset = 0;
60
+ while (offset < this.pending.length) {
61
+ const value = this.pending.slice(offset);
62
+ const first = value[0];
63
+ if (first === undefined)
64
+ break;
65
+ if (first === ESC) {
66
+ const length = escapeSequenceLength(value);
67
+ if (length === null) {
68
+ if (flush) {
69
+ keys.push(value);
70
+ offset = this.pending.length;
71
+ }
72
+ break;
73
+ }
74
+ keys.push(value.slice(0, length));
75
+ offset += length;
76
+ continue;
77
+ }
78
+ if (isControl(first)) {
79
+ keys.push(first);
80
+ offset += 1;
81
+ continue;
82
+ }
83
+ let end = offset;
84
+ while (end < this.pending.length) {
85
+ const char = this.pending[end];
86
+ if (char === undefined || char === ESC || isControl(char))
87
+ break;
88
+ end += char.length;
89
+ }
90
+ const run = this.pending.slice(offset, end);
91
+ const segments = Array.from(GRAPHEME_SEGMENTER.segment(run), ({ segment }) => segment);
92
+ for (const segment of segments) {
93
+ keys.push(segment);
94
+ offset += segment.length;
95
+ }
96
+ }
97
+ this.pending = this.pending.slice(offset);
98
+ return keys;
99
+ }
100
+ }
1
101
  function switchResult(target) {
2
102
  return target === undefined ? { handled: true } : { switchTo: target, handled: true };
3
103
  }
@@ -2,6 +2,7 @@ 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
4
  import { visualWidth, wrapAnsiToVisualWidth, wrapAnsiWithIndent } from '../../core/text.js';
5
+ import { loadingLines } from '../../core/components/spinner.js';
5
6
  function hintLines(label, cols) {
6
7
  return wrapAnsiWithIndent(type.hint(label), cols, space.indent);
7
8
  }
@@ -36,7 +37,7 @@ export function renderDocs(state, cols = 80, bodyRows = Number.POSITIVE_INFINITY
36
37
  let lines;
37
38
  switch (state.mode) {
38
39
  case 'loading':
39
- lines = hintLines(trans.common.loading, cols);
40
+ lines = loadingLines(trans.common.loading, cols);
40
41
  break;
41
42
  case 'sections':
42
43
  lines = state.sectionsField?.render(bodyRows, cols) ?? [];
@@ -66,7 +67,7 @@ export function renderDocs(state, cols = 80, bodyRows = Number.POSITIVE_INFINITY
66
67
  : [];
67
68
  break;
68
69
  case 'readerLoading':
69
- lines = hintLines(trans.docs.loadingFile, cols);
70
+ lines = loadingLines(trans.docs.loadingFile, cols);
70
71
  break;
71
72
  case 'reader':
72
73
  lines = state.readerLinksField
@@ -4,9 +4,9 @@ import { TextField } from '../fields/text-field.js';
4
4
  import { renderDocs } from './docs-render.js';
5
5
  import { setVimKeysActive } from '../../core/vim-keys.js';
6
6
  import { pickIcon } from '../../core/icons.js';
7
- import { getCurrentLanguage, t } from '../../i18n/index.js';
7
+ import { fmt, getCurrentLanguage, t } from '../../i18n/index.js';
8
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
+ import { localizeDocSections, fetchSections, fetchDocMetadata, fetchSectionMetadata, searchDocuments, getArchivedGroups, displayDocTitle, loadDocForReader, openDocsInBrowser, docsUrlFromPath, clearDocsCache, } from '../../features/docs.js';
10
10
  let state = { mode: 'loading' };
11
11
  let sections = [];
12
12
  let archivedGroups = new Map();
@@ -23,7 +23,11 @@ let readerNavStack = [];
23
23
  let readerPrevState = null;
24
24
  let readerLoadingPrevState = null;
25
25
  let readerRequestId = 0;
26
+ let lifecycleGeneration = 0;
26
27
  const DOC_HINT_WIDTH = 44;
28
+ function isLifecycleActive(ctx, generation) {
29
+ return generation === lifecycleGeneration && ctx.signal?.aborted !== true;
30
+ }
27
31
  function backLabel() {
28
32
  return t().common.back;
29
33
  }
@@ -43,6 +47,27 @@ function withoutErrorMessage(value) {
43
47
  delete next.errorMessage;
44
48
  return next;
45
49
  }
50
+ async function runBrowserOpen(ctx, path) {
51
+ let opened;
52
+ await ctx.runClassic(async () => {
53
+ opened =
54
+ path === undefined
55
+ ? await openDocsInBrowser(undefined, ctx.signal)
56
+ : await openDocsInBrowser(path, ctx.signal);
57
+ });
58
+ return opened;
59
+ }
60
+ async function openBrowserFromView(ctx, path, generation = lifecycleGeneration) {
61
+ const opened = await runBrowserOpen(ctx, path);
62
+ if (!isLifecycleActive(ctx, generation) || opened === true)
63
+ return;
64
+ const url = docsUrlFromPath(path);
65
+ state = {
66
+ ...state,
67
+ errorMessage: sanitizeTerminalLine(`${t().docs.browserError}. ${fmt(t().links.openManually, { url })}`),
68
+ };
69
+ ctx.rerender();
70
+ }
46
71
  function buildSectionsField() {
47
72
  const trans = t();
48
73
  const options = [
@@ -184,6 +209,9 @@ function replaceSection(section) {
184
209
  sections = sections.map((current) => (current.key === section.key ? section : current));
185
210
  }
186
211
  async function openSectionFiles(ctx, section) {
212
+ const generation = lifecycleGeneration;
213
+ if (!isLifecycleActive(ctx, generation))
214
+ return;
187
215
  const requestId = ++metadataRequestId;
188
216
  currentSectionKey = section.key;
189
217
  state = {
@@ -192,8 +220,9 @@ async function openSectionFiles(ctx, section) {
192
220
  };
193
221
  ctx.rerender();
194
222
  try {
195
- const hydrated = await fetchSectionMetadata(section);
196
- if (requestId !== metadataRequestId ||
223
+ const hydrated = await fetchSectionMetadata(section, ctx.signal);
224
+ if (!isLifecycleActive(ctx, generation) ||
225
+ requestId !== metadataRequestId ||
197
226
  state.mode !== 'files' ||
198
227
  currentSectionKey !== section.key)
199
228
  return;
@@ -205,15 +234,20 @@ async function openSectionFiles(ctx, section) {
205
234
  };
206
235
  }
207
236
  catch {
208
- if (requestId !== metadataRequestId ||
237
+ if (!isLifecycleActive(ctx, generation) ||
238
+ requestId !== metadataRequestId ||
209
239
  state.mode !== 'files' ||
210
240
  currentSectionKey !== section.key)
211
241
  return;
212
242
  state = { ...state, errorMessage: t().docs.loadError };
213
243
  }
214
- ctx.rerender();
244
+ if (isLifecycleActive(ctx, generation))
245
+ ctx.rerender();
215
246
  }
216
247
  async function openArchivedFiles(ctx, groupKey, groupFiles) {
248
+ const generation = lifecycleGeneration;
249
+ if (!isLifecycleActive(ctx, generation))
250
+ return;
217
251
  const requestId = ++metadataRequestId;
218
252
  currentArchivedGroupKey = groupKey;
219
253
  state = {
@@ -222,8 +256,9 @@ async function openArchivedFiles(ctx, groupKey, groupFiles) {
222
256
  };
223
257
  ctx.rerender();
224
258
  try {
225
- const hydrated = await fetchDocMetadata(groupFiles);
226
- if (requestId !== metadataRequestId ||
259
+ const hydrated = await fetchDocMetadata(groupFiles, ctx.signal);
260
+ if (!isLifecycleActive(ctx, generation) ||
261
+ requestId !== metadataRequestId ||
227
262
  state.mode !== 'archivedFiles' ||
228
263
  currentArchivedGroupKey !== groupKey)
229
264
  return;
@@ -234,7 +269,8 @@ async function openArchivedFiles(ctx, groupKey, groupFiles) {
234
269
  };
235
270
  }
236
271
  catch {
237
- if (requestId !== metadataRequestId ||
272
+ if (!isLifecycleActive(ctx, generation) ||
273
+ requestId !== metadataRequestId ||
238
274
  state.mode !== 'archivedFiles' ||
239
275
  currentArchivedGroupKey !== groupKey)
240
276
  return;
@@ -243,15 +279,19 @@ async function openArchivedFiles(ctx, groupKey, groupFiles) {
243
279
  errorMessage: t().docs.loadError,
244
280
  };
245
281
  }
246
- ctx.rerender();
282
+ if (isLifecycleActive(ctx, generation))
283
+ ctx.rerender();
247
284
  }
248
285
  async function runSearch(ctx, query) {
286
+ const generation = lifecycleGeneration;
287
+ if (!isLifecycleActive(ctx, generation))
288
+ return;
249
289
  const requestId = ++searchRequestId;
250
290
  state = { mode: 'searchLoading' };
251
291
  ctx.rerender();
252
292
  try {
253
- const matches = await searchDocuments(query);
254
- if (requestId !== searchRequestId)
293
+ const matches = await searchDocuments(query, ctx.signal);
294
+ if (!isLifecycleActive(ctx, generation) || requestId !== searchRequestId)
255
295
  return;
256
296
  currentSearchResults = matches;
257
297
  state = {
@@ -261,15 +301,19 @@ async function runSearch(ctx, query) {
261
301
  };
262
302
  }
263
303
  catch {
264
- if (requestId !== searchRequestId)
304
+ if (!isLifecycleActive(ctx, generation) || requestId !== searchRequestId)
265
305
  return;
266
306
  currentSearchResults = [];
267
307
  goToSections();
268
308
  state = { ...state, errorMessage: t().docs.loadError };
269
309
  }
270
- ctx.rerender();
310
+ if (isLifecycleActive(ctx, generation))
311
+ ctx.rerender();
271
312
  }
272
313
  async function openInReader(ctx, path, pushCurrent) {
314
+ const generation = lifecycleGeneration;
315
+ if (!isLifecycleActive(ctx, generation))
316
+ return;
273
317
  const requestId = ++readerRequestId;
274
318
  const previousState = state;
275
319
  const previousPath = readerCurrentPath;
@@ -277,8 +321,8 @@ async function openInReader(ctx, path, pushCurrent) {
277
321
  state = { mode: 'readerLoading' };
278
322
  ctx.rerender();
279
323
  try {
280
- const doc = await loadDocForReader(path);
281
- if (requestId !== readerRequestId)
324
+ const doc = await loadDocForReader(path, ctx.signal);
325
+ if (!isLifecycleActive(ctx, generation) || requestId !== readerRequestId)
282
326
  return;
283
327
  if (pushCurrent && previousPath)
284
328
  readerNavStack.push(previousPath);
@@ -293,7 +337,7 @@ async function openInReader(ctx, path, pushCurrent) {
293
337
  ctx.resetScroll();
294
338
  }
295
339
  catch {
296
- if (requestId !== readerRequestId)
340
+ if (!isLifecycleActive(ctx, generation) || requestId !== readerRequestId)
297
341
  return;
298
342
  readerLoadingPrevState = null;
299
343
  const fallbackState = pushCurrent && previousState.mode === 'reader'
@@ -301,7 +345,8 @@ async function openInReader(ctx, path, pushCurrent) {
301
345
  : previousState;
302
346
  state = { ...fallbackState, errorMessage: t().docs.loadError };
303
347
  }
304
- ctx.rerender();
348
+ if (isLifecycleActive(ctx, generation))
349
+ ctx.rerender();
305
350
  }
306
351
  function enterReaderFrom(ctx, path) {
307
352
  readerPrevState = state;
@@ -313,6 +358,9 @@ export const docsView = {
313
358
  id: 'docs',
314
359
  title: t().menu.docs,
315
360
  async load(ctx) {
361
+ const generation = loaded ? lifecycleGeneration : ++lifecycleGeneration;
362
+ if (!isLifecycleActive(ctx, generation))
363
+ return;
316
364
  if (loaded) {
317
365
  const language = getCurrentLanguage();
318
366
  if (loadedLanguage !== language) {
@@ -330,8 +378,8 @@ export const docsView = {
330
378
  state = { mode: 'loading' };
331
379
  ctx.rerender();
332
380
  try {
333
- const nextSections = await fetchSections();
334
- if (requestId !== sectionsRequestId)
381
+ const nextSections = await fetchSections(ctx.signal);
382
+ if (!isLifecycleActive(ctx, generation) || requestId !== sectionsRequestId)
335
383
  return;
336
384
  sections = nextSections;
337
385
  loaded = true;
@@ -339,11 +387,21 @@ export const docsView = {
339
387
  goToSections();
340
388
  }
341
389
  catch {
342
- if (requestId !== sectionsRequestId)
390
+ if (!isLifecycleActive(ctx, generation) || requestId !== sectionsRequestId)
343
391
  return;
344
392
  state = { mode: 'error', errorMessage: t().docs.loadError };
345
393
  }
346
- ctx.rerender();
394
+ if (isLifecycleActive(ctx, generation))
395
+ ctx.rerender();
396
+ },
397
+ dispose() {
398
+ lifecycleGeneration += 1;
399
+ sectionsRequestId += 1;
400
+ metadataRequestId += 1;
401
+ searchRequestId += 1;
402
+ readerRequestId += 1;
403
+ clearDocsCache();
404
+ setVimKeysActive(true);
347
405
  },
348
406
  render(ctx) {
349
407
  const maxVisible = computeMaxVisible(ctx.bodyRows);
@@ -354,6 +412,9 @@ export const docsView = {
354
412
  state.readerLinksField?.setMaxVisible(maxVisible);
355
413
  return renderDocs(state, ctx.size.cols, ctx.bodyRows);
356
414
  },
415
+ isBusy() {
416
+ return (state.mode === 'loading' || state.mode === 'searchLoading' || state.mode === 'readerLoading');
417
+ },
357
418
  capturesInput() {
358
419
  return state.mode === 'search';
359
420
  },
@@ -479,7 +540,7 @@ export const docsView = {
479
540
  return;
480
541
  }
481
542
  if (result.selected === '__browser__') {
482
- void ctx.runClassic(() => openDocsInBrowser());
543
+ void openBrowserFromView(ctx);
483
544
  return;
484
545
  }
485
546
  const section = sections.find((s) => s.key === result.selected);
@@ -586,7 +647,7 @@ export const docsView = {
586
647
  return;
587
648
  }
588
649
  if (key === 'b') {
589
- void ctx.runClassic(() => openDocsInBrowser(readerCurrentPath ?? undefined));
650
+ void openBrowserFromView(ctx, readerCurrentPath ?? undefined);
590
651
  return;
591
652
  }
592
653
  return;
@@ -4,6 +4,7 @@ import { renderListFieldWithContext } from '../fields/list-field.js';
4
4
  import { renderCountdownBanner, renderEventBrief } from '../../features/calendar.js';
5
5
  import { renderHeatmap } from '../../features/calendar-heatmap.js';
6
6
  import { wrapAnsiWithIndent } from '../../core/text.js';
7
+ import { loadingLines } from '../../core/components/spinner.js';
7
8
  function wrappedIndentedLines(label, cols, style) {
8
9
  return wrapAnsiWithIndent(style(label), cols ?? Number.POSITIVE_INFINITY, space.indent);
9
10
  }
@@ -61,7 +62,7 @@ export function renderEvents(state, now, bodyRows = 100, cols) {
61
62
  const trans = t();
62
63
  switch (state.mode) {
63
64
  case 'loading':
64
- return wrappedIndentedLines(trans.calendar.loading, cols, type.hint);
65
+ return loadingLines(trans.calendar.loading, cols);
65
66
  case 'hub':
66
67
  return renderHubBody(state, now, bodyRows, cols);
67
68
  case 'heatmap':
@@ -93,21 +93,32 @@ export const eventsView = {
93
93
  id: 'events',
94
94
  title: t().menu.events,
95
95
  async load(ctx) {
96
+ if (ctx.signal?.aborted)
97
+ return;
96
98
  state = { mode: 'loading' };
97
99
  ctx.rerender();
98
100
  try {
99
- calendar = await loadCalendarOrThrow();
101
+ const loadedCalendar = await loadCalendarOrThrow(ctx.signal);
102
+ if (ctx.signal?.aborted)
103
+ return;
104
+ calendar = loadedCalendar;
100
105
  goToHub();
101
106
  }
102
107
  catch {
108
+ if (ctx.signal?.aborted)
109
+ return;
103
110
  state = { mode: 'error', errorMessage: t().calendar.error };
104
111
  }
105
- ctx.rerender();
112
+ if (!ctx.signal?.aborted)
113
+ ctx.rerender();
106
114
  },
107
115
  render(ctx) {
108
116
  state.listField?.setMaxVisible(computeMaxVisible(ctx.bodyRows));
109
117
  return renderEvents(state, new Date(), ctx.bodyRows, ctx.size.cols);
110
118
  },
119
+ isBusy() {
120
+ return state.mode === 'loading';
121
+ },
111
122
  capturesInput() {
112
123
  return state.mode === 'search';
113
124
  },