diff --git a/src/app/chrome.test.ts b/src/app/chrome.test.ts index 5d61766..e58d912 100644 --- a/src/app/chrome.test.ts +++ b/src/app/chrome.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { glyph } from '../core/theme.js'; import { captureFooterHint, offlineNotice, @@ -258,7 +259,7 @@ describe('captureFooterHint', () => { expect(visualWidth(hint)).toBeLessThanOrEqual(20); expect(hint).toContain('Ctrl+C'); expect(hint).toContain('Esc'); - expect(hint).toContain('Enter'); + expect(hint).toContain(glyph.enter()); }); }); diff --git a/src/app/chrome.ts b/src/app/chrome.ts index 0a4939e..9ceb22d 100644 --- a/src/app/chrome.ts +++ b/src/app/chrome.ts @@ -130,8 +130,8 @@ export function captureFooterHint(cols = Number.POSITIVE_INFINITY): string { const dot = pickIcon('·', '-'); return fitFooterHint( cols, - `Ctrl+C ${trans.common.exit} ${dot} Esc ${trans.common.back} ${dot} Enter ${trans.common.confirm}`, - 'Ctrl+C Esc Enter', + `${glyph.enter()} ${trans.common.confirm} ${dot} Esc ${trans.common.back} ${dot} Ctrl+C ${trans.common.exit}`, + `${glyph.enter()} Esc Ctrl+C`, 'Ctrl+C Esc', 'Ctrl+C', ); diff --git a/src/app/views/docs-render.ts b/src/app/views/docs-render.ts index 1a0afa5..7230a9a 100644 --- a/src/app/views/docs-render.ts +++ b/src/app/views/docs-render.ts @@ -1,5 +1,5 @@ import { type, space, bodyEdge } from '../../core/theme.js'; -import { t } from '../../i18n/index.js'; +import { fmt, t } from '../../i18n/index.js'; import { type ListField, renderListFieldWithContext } from '../fields/list-field.js'; import { offlineNotice } from '../chrome.js'; import type { TextField } from '../fields/text-field.js'; @@ -23,6 +23,7 @@ export type DocsMode = export interface DocsViewState { mode: DocsMode; errorMessage?: string; + errorField?: ListField; stale?: boolean; sectionsField?: ListField; filesField?: ListField; @@ -31,6 +32,7 @@ export interface DocsViewState { searchField?: TextField; searchResultsField?: ListField; searchResultsEmpty?: boolean; + searchProgress?: { done: number; total: number }; readerTitle?: string; readerRender?: (width: number) => string[]; readerLinks?: DocLink[]; @@ -61,6 +63,7 @@ function listFieldForState(state: DocsViewState): ListField | undefined { case 'reader': return state.readerLinksField; case 'error': + return state.errorField; case 'loading': case 'readerLoading': case 'search': @@ -80,16 +83,16 @@ export function renderDocs( case 'loading': lines = loadingLines(trans.common.loading, cols); break; - case 'sections': + case 'sections': { + const context = state.stale ? offlineNotice(cols) : []; + if (Number.isFinite(bodyRows)) { + state.sectionsField?.setMaxVisible(Math.max(1, Math.floor(bodyRows) - context.length - 1)); + } lines = state.sectionsField - ? renderListFieldWithContext( - state.stale ? offlineNotice(cols) : [], - state.sectionsField, - bodyRows, - cols, - ) + ? renderListFieldWithContext(context, state.sectionsField, bodyRows, cols) : []; break; + } case 'files': lines = state.filesField?.render(bodyRows, cols) ?? loadingLines(trans.common.loading, cols); break; @@ -105,7 +108,12 @@ export function renderDocs( lines = state.searchField?.render(cols) ?? []; break; case 'searchLoading': - lines = loadingLines(trans.docs.searching, cols); + lines = loadingLines( + state.searchProgress + ? fmt(trans.docs.searchProgress, state.searchProgress) + : trans.docs.searching, + cols, + ); break; case 'searchResults': lines = state.searchResultsField @@ -129,8 +137,20 @@ export function renderDocs( ? state.readerLinksField.render(bodyRows, cols) : renderReader(state.readerRender ?? (() => []), cols); break; - case 'error': - return hintLines(state.errorMessage ?? trans.docs.loadError, cols); + case 'error': { + const context = [ + ...wrapAnsiWithIndent( + type.body(state.errorMessage ?? trans.docs.loadError), + cols, + space.indent, + ), + ...hintLines(trans.docs.offlineHint, cols), + '', + ]; + return state.errorField + ? renderListFieldWithContext(context, state.errorField, bodyRows, cols) + : context; + } } if (!state.errorMessage) return lines; const errorContext = [...hintLines(state.errorMessage, cols), '']; diff --git a/src/app/views/docs.test.ts b/src/app/views/docs.test.ts index 75d6909..3bbc6bc 100644 --- a/src/app/views/docs.test.ts +++ b/src/app/views/docs.test.ts @@ -8,8 +8,14 @@ const fetchSectionsMock = vi.fn().mockResolvedValue([ label: 'Guide', count: 2, files: [ - { name: 'index.md', path: 'tutorial/index.md', type: 'file' }, - { name: 'os-skills.md', path: 'tutorial/manual/os-skills.md', type: 'file' }, + { name: 'index.md', path: 'tutorial/index.md', type: 'file', title: 'Guide', summary: '' }, + { + name: 'os-skills.md', + path: 'tutorial/manual/os-skills.md', + type: 'file', + title: 'Os Skills', + summary: '', + }, ], }, ]); @@ -19,7 +25,10 @@ const readerDocs: Record = { path: 'tutorial/manual/os-skills.md', title: 'OS Skills', render: () => ['OS Skills content', '', 'See also linked doc.'], - links: [{ href: 'tutorial/manual/other-doc.md', text: 'linked doc' }], + links: [ + { href: 'tutorial/manual/other-doc.md', text: 'linked doc' }, + { href: 'https://nbtca.space', text: 'the site', external: true }, + ], }, 'tutorial/manual/other-doc.md': { path: 'tutorial/manual/other-doc.md', @@ -36,7 +45,9 @@ function readerDoc(path: string): ReaderDoc { const loadDocForReaderMock = vi.fn((path: string) => Promise.resolve(readerDoc(path))); const fetchDocMetadataMock = vi.fn((files: ListedDoc[]) => Promise.resolve(files)); -const fetchSectionMetadataMock = vi.fn((section: DocSection) => Promise.resolve(section)); +const fetchSectionMetadataMock = vi.fn((section) => + Promise.resolve(section), +); const searchDocumentsMock = vi.fn().mockResolvedValue([]); const openDocsInBrowserMock = vi.fn().mockResolvedValue(true); const clearDocsCacheMock = vi.fn(); @@ -57,6 +68,9 @@ function deferred(): { return { promise, resolve }; } +const launchBrowserUrlMock = vi.fn().mockResolvedValue(true); +vi.mock('../../features/links.js', () => ({ launchBrowserUrl: launchBrowserUrlMock })); + vi.mock('../../features/docs.js', async (importOriginal) => { const actual = await importOriginal(); return { @@ -131,15 +145,25 @@ describe('docsView', () => { expect(hint).not.toContain(t().menu.hintOpen); }); - it('does not offer move or open actions on an error screen', async () => { + it('says the docs look offline and offers a retry when nothing is stored', async () => { fetchSectionsMock.mockRejectedValueOnce(new Error('Broke')); vi.resetModules(); const { docsView: freshDocsView } = await import('./docs.js'); - await freshDocsView.load(fakeCtx()); - const hint = stripAnsi(freshDocsView.footerHint(5, 80) ?? ''); - expect(hint).toContain('1-5'); - expect(hint).not.toContain(t().menu.hintMove); - expect(hint).not.toContain(t().menu.hintOpen); + const { setLanguage: setFreshLanguage } = await import('../../i18n/index.js'); + setFreshLanguage('en'); + const ctx = fakeCtx(); + await freshDocsView.load(ctx); + const out = stripAnsi(freshDocsView.render(ctx).join('\n')); + expect(out).toContain(t().docs.offlineError); + expect(out).toContain(t().docs.offlineHint); + expect(out).toContain(t().docs.retryLoad); + expect(freshDocsView.capturesPageKeys()).toBe(true); + + fetchSectionsMock.mockClear(); + freshDocsView.handleKey('\r', ctx); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fetchSectionsMock).toHaveBeenCalledOnce(); + expect(stripAnsi(freshDocsView.render(ctx).join('\n'))).toContain(t().docs.searchPrompt); }); it('rebuilds the cached section menu after a language change without fetching again', async () => { @@ -185,8 +209,14 @@ describe('docsView', () => { label: '指南', count: 2, files: [ - { name: 'index.md', path: 'tutorial/index.md', type: 'file' }, - { name: 'os-skills.md', path: 'tutorial/manual/os-skills.md', type: 'file' }, + { name: 'index.md', path: 'tutorial/index.md', type: 'file', title: '指南', summary: '' }, + { + name: 'os-skills.md', + path: 'tutorial/manual/os-skills.md', + type: 'file', + title: 'Os Skills', + summary: '', + }, ], }, ]); @@ -296,10 +326,10 @@ describe('docsView', () => { setFreshLanguage('en'); await freshDocsView.load(ctx); const out = stripAnsi(freshDocsView.render(ctx).join('\n')); - expect(out).toContain('Choose a document or directory'); + expect(out).toContain('Results for “o” · 1'); const selected = out.split('\n').find((line) => line.includes('Back')); expect(selected?.trim().startsWith('→')).toBe(true); - expect(searchDocumentsMock).toHaveBeenCalledWith('o', ctx.signal); + expect(searchDocumentsMock).toHaveBeenCalledWith('o', ctx.signal, expect.any(Function)); expect(fetchSectionsMock).toHaveBeenCalledTimes(1); } finally { setFreshLanguage('en'); @@ -555,18 +585,60 @@ describe('docsView native reader (no shell-out to less/glow)', () => { expect(fetchSectionMetadataMock).not.toHaveBeenCalled(); }); - it('shows filenames at once and swaps in titles when they arrive', async () => { + it('lets summaries fill a wide terminal in one aligned column', async () => { + const summary = 'Practical workstation skills for every new member, from shells to editors'; + peekListedDocsMock.mockReturnValueOnce({ + complete: true, + docs: [ + { name: 'a.md', path: 'tutorial/a.md', type: 'file', title: 'Handbook', summary }, + { + name: 'b.md', + path: 'tutorial/b.md', + type: 'file', + title: 'Shells', + summary: 'Terminals', + }, + ], + }); + const ctx = { ...fakeCtx(), size: { rows: 40, cols: 120 }, bodyRows: 35 }; + await freshDocsView.load(ctx); + freshDocsView.handleKey('\r', ctx); + + const lines = stripAnsi(freshDocsView.render(ctx).join('\n')).split('\n'); + const first = lines.find((line) => line.includes('Handbook')) ?? ''; + const second = lines.find((line) => line.includes('Shells')) ?? ''; + expect(first).toContain(summary); + expect(first.indexOf('Practical')).toBe(second.indexOf('Terminals')); + }); + + it('keeps unknown titles as placeholders and swaps in each title as it arrives', async () => { let resolveMetadata!: (section: DocSection) => void; + let reportDoc!: (index: number, doc: ListedDoc) => void; + peekListedDocsMock.mockImplementationOnce((items) => ({ + docs: items.map((item) => ({ ...item, title: '' })), + complete: false, + })); fetchSectionMetadataMock.mockImplementationOnce( - () => + (_section, _signal, onDoc) => new Promise((resolve) => { resolveMetadata = resolve; + if (onDoc) reportDoc = onDoc; }), ); const ctx = fakeCtx(); await openTutorialFiles(ctx); const early = stripAnsi(freshDocsView.render(ctx).join('\n')); - expect(early).toContain('Os Skills'); + expect(early).not.toContain('Os Skills'); + expect(early).toContain('…'); + + reportDoc(1, { + name: 'os-skills.md', + path: 'tutorial/manual/os-skills.md', + type: 'file', + title: 'Operating Systems Handbook', + summary: '', + }); + expect(stripAnsi(freshDocsView.render(ctx).join('\n'))).toContain('Operating Systems Handbook'); expect(freshDocsView.isBusy()).toBe(false); expect(freshDocsView.contextPath()).toEqual(['Docs', 'Guide']); @@ -599,6 +671,10 @@ describe('docsView native reader (no shell-out to less/glow)', () => { }); it('falls back to filename titles with an error when the titles cannot load', async () => { + peekListedDocsMock.mockImplementationOnce((items) => ({ + docs: items.map((item) => ({ ...item, title: '' })), + complete: false, + })); fetchSectionMetadataMock.mockRejectedValueOnce(new Error('offline')); const ctx = fakeCtx(); await openTutorialFiles(ctx); @@ -780,6 +856,51 @@ describe('docsView native reader (no shell-out to less/glow)', () => { expect(out).toContain('Other doc content, no further links.'); }); + it('lists external links with their address and opens them in the browser', async () => { + const ctx = fakeCtx(); + await openTutorialFiles(ctx); + freshDocsView.handleKey('\x1b[B', ctx); + freshDocsView.handleKey('\r', ctx); + await flush(); + + freshDocsView.handleKey('f', ctx); + const out = stripAnsi(freshDocsView.render(ctx).join('\n')); + expect(out.split('\n').find((line) => line.includes('the site'))).toContain('nbtca.space'); + + freshDocsView.handleKey('\x1b[B', ctx); + freshDocsView.handleKey('\r', ctx); + await flush(); + expect(launchBrowserUrlMock).toHaveBeenCalledWith('https://nbtca.space'); + expect(ctx.runClassic).toHaveBeenCalled(); + expect(stripAnsi(freshDocsView.render(ctx).join('\n'))).toContain('OS Skills content'); + }); + + it('counts the documents a search has checked while it runs', async () => { + let report!: (done: number, total: number) => void; + searchDocumentsMock.mockImplementationOnce( + (_query: string, _signal: AbortSignal, onProgress: (done: number, total: number) => void) => { + report = onProgress; + return new Promise(() => undefined); + }, + ); + const ctx = fakeCtx(); + await freshDocsView.load(ctx); + freshDocsView.handleKey('\x1b[B', ctx); + freshDocsView.handleKey('\r', ctx); + freshDocsView.handleKey('git', ctx); + freshDocsView.handleKey('\r', ctx); + report(12, 245); + expect(stripAnsi(freshDocsView.render(ctx).join('\n'))).toContain('12 of 245'); + }); + + it('keeps an overflow cue on a short docs menu', async () => { + const ctx = { ...fakeCtx(), size: { rows: 12, cols: 40 }, bodyRows: 4 }; + await freshDocsView.load(ctx); + const lines = stripAnsi(freshDocsView.render(ctx).join('\n')).split('\n'); + expect(lines.length).toBeLessThanOrEqual(4); + expect(lines.at(-1)).toContain('more below'); + }); + it('keeps the current document and navigation stack when an internal link fails to load', async () => { const ctx = fakeCtx(); await openTutorialFiles(ctx); @@ -811,7 +932,7 @@ describe('docsView native reader (no shell-out to less/glow)', () => { freshDocsView.handleKey('\r', ctx); await flush(); freshDocsView.handleKey('f', ctx); - freshDocsView.handleKey('\x1b[B', ctx); + freshDocsView.handleKey('G', ctx); setLanguage('en'); setFreshLanguage('en'); diff --git a/src/app/views/docs.ts b/src/app/views/docs.ts index efc5cf1..94009d6 100644 --- a/src/app/views/docs.ts +++ b/src/app/views/docs.ts @@ -7,7 +7,7 @@ import { setVimKeysActive } from '../../core/vim-keys.js'; import { pickIcon } from '../../core/icons.js'; import { glyph } from '../../core/theme.js'; import { fmt, getCurrentLanguage, t, type Language } from '../../i18n/index.js'; -import { sanitizeTerminalLine, truncate } from '../../core/text.js'; +import { sanitizeTerminalLine } from '../../core/text.js'; import { localizeDocSections, fetchSections, @@ -22,11 +22,13 @@ import { openDocsInBrowser, docsUrlFromPath, clearDocsCache, + withFileNameTitle, type DocSection, type DocLink, type ListedDoc, type SearchDoc, } from '../../features/docs.js'; +import { launchBrowserUrl } from '../../features/links.js'; let state: DocsViewState = { mode: 'loading' }; let sections: DocSection[] = []; @@ -37,6 +39,7 @@ let loadedLanguage: Language | null = null; let currentSectionKey: string | null = null; let currentArchivedGroupKey: string | null = null; let currentSearchResults: SearchDoc[] = []; +let currentSearchQuery = ''; let sectionsRequestId = 0; let metadataRequestId = 0; let searchRequestId = 0; @@ -48,8 +51,6 @@ let readerLoadingPrevState: DocsViewState | null = null; let readerRequestId = 0; let lifecycleGeneration = 0; -const DOC_HINT_WIDTH = 44; - function isLifecycleActive(ctx: AppContext, generation: number): boolean { return generation === lifecycleGeneration && ctx.signal?.aborted !== true; } @@ -66,12 +67,12 @@ function backLabel(): string { return t().common.back; } -function optionalHint(hint: string | undefined): { hint?: string } { - return hint === undefined ? {} : { hint }; +function optionalHint(hint: string | undefined): { hint?: string; hintColumn?: boolean } { + return hint ? { hint, hintColumn: true } : {}; } -function docHint(value: string | undefined): string | undefined { - return value ? truncate(value, DOC_HINT_WIDTH) : undefined; +function docLabel(doc: ListedDoc): { label: string; dim?: boolean } { + return doc.title ? { label: doc.title } : { label: pickIcon('…', '...'), dim: true }; } function withoutReaderLinksField(value: DocsViewState): DocsViewState { @@ -86,26 +87,31 @@ function withoutErrorMessage(value: DocsViewState): DocsViewState { return next; } -async function runBrowserOpen(ctx: AppContext, path?: string): Promise { - let opened: boolean | undefined; - await ctx.runClassic(async () => { - opened = - path === undefined - ? await openDocsInBrowser(undefined, ctx.signal) - : await openDocsInBrowser(path, ctx.signal); - }); - return opened; -} - async function openBrowserFromView( ctx: AppContext, path?: string, generation = lifecycleGeneration, ): Promise { - const opened = await runBrowserOpen(ctx, path); + await openUrlFromView( + ctx, + docsUrlFromPath(path), + () => openDocsInBrowser(path, ctx.signal), + generation, + ); +} + +async function openUrlFromView( + ctx: AppContext, + url: string, + open: () => Promise, + generation = lifecycleGeneration, +): Promise { + let opened: boolean | undefined; + await ctx.runClassic(async () => { + opened = await open(); + }); if (!isLifecycleActive(ctx, generation) || opened === true) return; - const url = docsUrlFromPath(path); state = { ...state, errorMessage: sanitizeTerminalLine( @@ -140,8 +146,8 @@ function buildFilesField(section: DocSection, maxVisible: number, initialIndex = ...(index ? [{ value: index.path, label: trans.docs.overviewLabel }] : []), ...files.map((file) => ({ value: file.path, - label: displayDocTitle(file.name, file.title), - ...optionalHint(docHint(file.summary)), + ...docLabel(file), + ...optionalHint(file.summary), })), { value: '__back__', label: backLabel() }, ]; @@ -182,13 +188,11 @@ function buildArchivedFilesField( const sub = f.path.split('/').slice(2, -1).join('/'); return { value: f.path, - label: displayDocTitle(f.name, f.title), + ...docLabel(f), ...optionalHint( - docHint( - subDirs.size > 1 - ? [sanitizeTerminalLine(sub), f.summary].filter(Boolean).join(' · ') - : f.summary, - ), + subDirs.size > 1 + ? [sanitizeTerminalLine(sub), f.summary].filter(Boolean).join(' · ') + : f.summary, ), }; }), @@ -205,7 +209,14 @@ function buildArchivedFilesField( function buildReaderLinksField(links: DocLink[], maxVisible: number, initialIndex = 0): ListField { const trans = t(); const options = [ - ...links.map((l) => ({ value: l.href, label: l.text })), + ...links.map((l) => { + const address = l.external ? l.href.replace(/^https?:\/\//i, '').replace(/\/$/, '') : ''; + return { + value: l.href, + label: l.text, + ...optionalHint(address === l.text ? undefined : address), + }; + }), { value: '__back__', label: backLabel() }, ]; return new ListField({ title: trans.docs.readerLinksTitle, options, maxVisible, initialIndex }); @@ -222,18 +233,21 @@ function buildSearchResultsField( value: result.path, label: displayDocTitle(result.name, result.title), ...optionalHint( - docHint( - result.excerpt || - result.summary || - (result.path.includes('/') - ? sanitizeTerminalLine(result.path.split('/').slice(0, -1).join('/')) - : undefined), - ), + result.excerpt || + result.summary || + (result.path.includes('/') + ? sanitizeTerminalLine(result.path.split('/').slice(0, -1).join('/')) + : undefined), ), })), { value: '__back__', label: backLabel() }, ]; - return new ListField({ title: trans.docs.chooseDoc, options, maxVisible, initialIndex }); + return new ListField({ + title: fmt(trans.docs.searchResultsTitle, { query: currentSearchQuery, count: matches.length }), + options, + maxVisible, + initialIndex, + }); } function relocalizeStateFields(value: DocsViewState, maxVisible: number): DocsViewState { @@ -300,6 +314,20 @@ function goToSections(): void { state = { mode: 'sections', sectionsField: buildSectionsField(), stale }; } +function showLoadError(): void { + const trans = t(); + state = { + mode: 'error', + errorMessage: trans.docs.offlineError, + errorField: new ListField({ + options: [ + { value: '__retry__', label: trans.docs.retryLoad }, + { value: '__browser__', label: trans.docs.openBrowser }, + ], + }), + }; +} + function replaceSection(section: DocSection): void { sections = sections.map((current) => (current.key === section.key ? section : current)); } @@ -311,44 +339,45 @@ async function openSectionFiles(ctx: AppContext, section: DocSection): Promise + isLifecycleActive(ctx, generation) && + requestId === metadataRequestId && + state.mode === 'files' && + currentSectionKey === section.key; + const show = (value: DocSection, extra: Partial = {}) => { + state = { + mode: 'files', + filesField: buildFilesField( + value, + computeMaxVisible(ctx.bodyRows), + state.filesField?.selectedIndex, + ), + ...extra, + }; + }; + show(known); if (stored.complete) { replaceSection(known); return; } ctx.rerender(); try { - const hydrated = await fetchSectionMetadata(section, ctx.signal); - if ( - !isLifecycleActive(ctx, generation) || - requestId !== metadataRequestId || - state.mode !== 'files' || - currentSectionKey !== section.key - ) - return; + const hydrated = await fetchSectionMetadata(section, ctx.signal, (index, doc) => { + if (!isCurrent() || known.files[index]?.title) return; + known.files[index] = doc; + show(known); + ctx.rerender(); + }); + if (!isCurrent()) return; const localized = localizeDocSections([hydrated], t())[0] ?? hydrated; replaceSection(localized); - state = { - mode: 'files', - filesField: buildFilesField( - localized, - computeMaxVisible(ctx.bodyRows), - state.filesField?.selectedIndex, - ), - }; + show(localized); } catch { - if ( - !isLifecycleActive(ctx, generation) || - requestId !== metadataRequestId || - state.mode !== 'files' || - currentSectionKey !== section.key - ) - return; - state = { - mode: 'files', - filesField: buildFilesField(section, computeMaxVisible(ctx.bodyRows)), - errorMessage: t().docs.loadError, - }; + if (!isCurrent()) return; + show( + { ...known, files: known.files.map(withFileNameTitle) }, + { errorMessage: t().docs.loadError }, + ); } if (isLifecycleActive(ctx, generation)) ctx.rerender(); } @@ -363,55 +392,42 @@ async function openArchivedFiles( const requestId = ++metadataRequestId; currentArchivedGroupKey = groupKey; const stored = peekListedDocs(groupFiles); - state = { - mode: 'archivedFiles', - archivedFilesField: buildArchivedFilesField( - groupKey, - stored.docs, - computeMaxVisible(ctx.bodyRows), - ), - }; - if (stored.complete) { - archivedGroups.set(groupKey, stored.docs); - return; - } - ctx.rerender(); - try { - const hydrated = await fetchDocMetadata(groupFiles, ctx.signal); - if ( - !isLifecycleActive(ctx, generation) || - requestId !== metadataRequestId || - state.mode !== 'archivedFiles' || - currentArchivedGroupKey !== groupKey - ) - return; - archivedGroups.set(groupKey, hydrated); + const isCurrent = () => + isLifecycleActive(ctx, generation) && + requestId === metadataRequestId && + state.mode === 'archivedFiles' && + currentArchivedGroupKey === groupKey; + const show = (files: ListedDoc[], extra: Partial = {}) => { state = { mode: 'archivedFiles', archivedFilesField: buildArchivedFilesField( groupKey, - hydrated, + files, computeMaxVisible(ctx.bodyRows), state.archivedFilesField?.selectedIndex, ), + ...extra, }; + }; + show(stored.docs); + if (stored.complete) { + archivedGroups.set(groupKey, stored.docs); + return; + } + ctx.rerender(); + try { + const hydrated = await fetchDocMetadata(groupFiles, ctx.signal, (index, doc) => { + if (!isCurrent() || stored.docs[index]?.title) return; + stored.docs[index] = doc; + show(stored.docs); + ctx.rerender(); + }); + if (!isCurrent()) return; + archivedGroups.set(groupKey, hydrated); + show(hydrated); } catch { - if ( - !isLifecycleActive(ctx, generation) || - requestId !== metadataRequestId || - state.mode !== 'archivedFiles' || - currentArchivedGroupKey !== groupKey - ) - return; - state = { - mode: 'archivedFiles', - archivedFilesField: buildArchivedFilesField( - groupKey, - groupFiles, - computeMaxVisible(ctx.bodyRows), - ), - errorMessage: t().docs.loadError, - }; + if (!isCurrent()) return; + show(stored.docs.map(withFileNameTitle), { errorMessage: t().docs.loadError }); } if (isLifecycleActive(ctx, generation)) ctx.rerender(); } @@ -423,8 +439,13 @@ async function runSearch(ctx: AppContext, query: string): Promise { state = { mode: 'searchLoading' }; ctx.rerender(); try { - const matches = await searchDocuments(query, ctx.signal); + const matches = await searchDocuments(query, ctx.signal, (done, total) => { + if (!isLifecycleActive(ctx, generation) || requestId !== searchRequestId) return; + state = { mode: 'searchLoading', searchProgress: { done, total } }; + ctx.rerender(); + }); if (!isLifecycleActive(ctx, generation) || requestId !== searchRequestId) return; + currentSearchQuery = query; currentSearchResults = matches; state = { mode: 'searchResults', @@ -481,6 +502,26 @@ function enterReaderFrom(ctx: AppContext, path: string): void { void openInReader(ctx, path, false); } +function reloadDocs(ctx: AppContext): void { + clearDocsCache(); + sectionsRequestId++; + metadataRequestId++; + searchRequestId++; + loaded = false; + loadedLanguage = null; + sections = []; + archivedGroups = new Map(); + currentSectionKey = null; + currentArchivedGroupKey = null; + currentSearchResults = []; + readerCurrentPath = null; + readerNavStack = []; + readerPrevState = null; + readerLoadingPrevState = null; + readerRequestId++; + void docsView.load(ctx); +} + export const docsView = { id: 'docs', title: t().menu.docs, @@ -528,7 +569,7 @@ export const docsView = { stale = true; if (state.mode === 'sections') state = { ...state, stale }; } else { - state = { mode: 'error', errorMessage: t().docs.loadError }; + showLoadError(); } } if (isLifecycleActive(ctx, generation)) ctx.rerender(); @@ -617,13 +658,18 @@ export const docsView = { state.mode === 'archivedGroups' || state.mode === 'archivedFiles' || state.mode === 'searchResults' || + (state.mode === 'error' && state.errorField !== undefined) || (state.mode === 'reader' && state.readerLinksField !== undefined) ); }, footerHint(tabCount: number, cols = Number.POSITIVE_INFINITY): string | undefined { if (state.mode === 'search') return captureFooterHint(cols); - if (state.mode === 'loading' || state.mode === 'searchLoading' || state.mode === 'error') + if ( + state.mode === 'loading' || + state.mode === 'searchLoading' || + (state.mode === 'error' && !state.errorField) + ) return passiveFooterHint(tabCount, cols); if (state.mode === 'readerLoading') { return fitFooterHint( @@ -732,23 +778,7 @@ export const docsView = { return; } if (result.selected === '__refresh__') { - clearDocsCache(); - sectionsRequestId++; - metadataRequestId++; - searchRequestId++; - loaded = false; - loadedLanguage = null; - sections = []; - archivedGroups = new Map(); - currentSectionKey = null; - currentArchivedGroupKey = null; - currentSearchResults = []; - readerCurrentPath = null; - readerNavStack = []; - readerPrevState = null; - readerLoadingPrevState = null; - readerRequestId++; - void docsView.load(ctx); + reloadDocs(ctx); return; } if (result.selected === '__browser__') { @@ -847,7 +877,14 @@ export const docsView = { state = withoutReaderLinksField(state); return; } - if (result.selected) void openInReader(ctx, result.selected, true); + const link = state.readerLinks?.find((candidate) => candidate.href === result.selected); + if (link?.external) { + const href = link.href; + state = withoutReaderLinksField(state); + void openUrlFromView(ctx, href, () => launchBrowserUrl(href)); + } else if (result.selected) { + void openInReader(ctx, result.selected, true); + } return; } if (key === 'f' && (state.readerLinks?.length ?? 0) > 0) { @@ -866,7 +903,12 @@ export const docsView = { } return; } - case 'error': + case 'error': { + const result = state.errorField?.handleKey(key); + if (result?.selected === '__retry__') reloadDocs(ctx); + if (result?.selected === '__browser__') void openBrowserFromView(ctx); + return; + } case 'loading': case 'readerLoading': case 'searchLoading': diff --git a/src/app/views/events-render.test.ts b/src/app/views/events-render.test.ts index ca2f82a..7e090f7 100644 --- a/src/app/views/events-render.test.ts +++ b/src/app/views/events-render.test.ts @@ -682,6 +682,6 @@ describe('renderEvents — list/detail/search/error modes', () => { expect(lines.length).toBeLessThanOrEqual(6); expect(lines.every((line) => visualWidth(line) <= 20)).toBe(true); - expect(text).toBe(message.replace(/\s/g, '')); + expect(text.startsWith(message.replace(/\s/g, ''))).toBe(true); }); }); diff --git a/src/app/views/events-render.ts b/src/app/views/events-render.ts index d8cfbab..1ddb7df 100644 --- a/src/app/views/events-render.ts +++ b/src/app/views/events-render.ts @@ -18,6 +18,7 @@ export type EventsMode = 'loading' | 'hub' | 'heatmap' | 'list' | 'detail' | 'se export interface EventsViewState { mode: EventsMode; errorMessage?: string; + errorField?: ListField; statusMessage?: string; stale?: boolean; nextEvent?: Event; @@ -45,6 +46,17 @@ function wrappedIndentedLines( const EXPANDED_HUB_MIN_BODY_ROWS = 29; +export function hubShowsHeatmap(bodyRows: number, buckets: readonly HeatmapBucket[] = []): boolean { + return bodyRows >= EXPANDED_HUB_MIN_BODY_ROWS && buckets.length > 0; +} + +function heatmapLines(buckets: HeatmapBucket[], now: Date, cols?: number): string[] { + return renderHeatmap(buckets, now, { + color: true, + ...(cols === undefined ? {} : { cols }), + }).split('\n'); +} + function renderHubBody( state: EventsViewState, now: Date, @@ -60,15 +72,8 @@ function renderHubBody( const banner = renderCountdownBanner(state.nextEvent, now, cols); if (banner) lines.push(...banner.split('\n'), ''); const buckets = state.heatmapBuckets; - if (bodyRows >= EXPANDED_HUB_MIN_BODY_ROWS && buckets && buckets.length > 0) { - lines.push( - ...renderHeatmap(buckets, now, { - color: true, - ...(cols === undefined ? {} : { cols }), - }).split('\n'), - ); - lines.push(''); - } + if (buckets && hubShowsHeatmap(bodyRows, buckets)) + lines.push(...heatmapLines(buckets, now, cols), ''); if (state.recentEvents && state.recentEvents.length > 0) { const activityHeading = wrappedIndentedLines(trans.calendar.recentActivity, cols, type.heading); const fieldRows = state.hubField @@ -110,31 +115,36 @@ export function renderEvents( cols?: number, ): string[] { const trans = t(); + const notice = state.stale ? offlineNotice(cols ?? Number.POSITIVE_INFINITY) : []; switch (state.mode) { case 'loading': return loadingLines(trans.calendar.loading, cols); case 'hub': return renderHubBody(state, now, bodyRows, cols); case 'heatmap': - return state.heatmapBuckets && state.heatmapBuckets.length > 0 - ? renderHeatmap(state.heatmapBuckets, now, { - color: true, - ...(cols === undefined ? {} : { cols }), - }).split('\n') - : wrappedIndentedLines(trans.calendar.noEvents, cols, type.hint); + return [ + ...notice, + ...(state.heatmapBuckets && state.heatmapBuckets.length > 0 + ? heatmapLines(state.heatmapBuckets, now, cols) + : wrappedIndentedLines(trans.calendar.noEvents, cols, type.hint)), + ]; case 'list': { if (!state.listField) return []; - if (state.listNotice === undefined) return state.listField.render(bodyRows, cols); - const context = [ - ...wrappedIndentedLines(state.listTitle ?? '', cols, type.heading), - '', - ...wrappedIndentedLines(state.listNotice, cols, type.hint), - '', - ]; + const context = + state.listNotice === undefined + ? notice + : [ + ...notice, + ...wrappedIndentedLines(state.listTitle ?? '', cols, type.heading), + '', + ...wrappedIndentedLines(state.listNotice, cols, type.hint), + '', + ]; return renderListFieldWithContext(context, state.listField, bodyRows, cols); } case 'detail': { const context = [ + ...notice, ...wrappedIndentedLines(state.detailTitle ?? '', cols, type.heading), ...wrappedIndentedLines(state.detailMeta ?? '', cols, type.hint), '', @@ -155,9 +165,20 @@ export function renderEvents( : context; } case 'search': - return state.searchField?.render(cols) ?? []; - case 'error': - return wrappedIndentedLines(state.errorMessage ?? trans.calendar.error, cols, type.hint); + return [...notice, ...(state.searchField?.render(cols) ?? [])]; + case 'error': { + const context = [ + ...wrappedIndentedLines(state.errorMessage ?? trans.calendar.error, cols, type.body), + ...wrappedIndentedLines(trans.calendar.errorHint, cols, type.hint), + '', + ]; + if (state.errorField) { + return renderListFieldWithContext(context, state.errorField, bodyRows, cols); + } + return Number.isFinite(bodyRows) + ? context.slice(0, Math.max(0, Math.floor(bodyRows))) + : context; + } default: return []; } diff --git a/src/app/views/events.test.ts b/src/app/views/events.test.ts index 19ba6cb..da28f3a 100644 --- a/src/app/views/events.test.ts +++ b/src/app/views/events.test.ts @@ -87,13 +87,19 @@ describe('eventsView', () => { expect(hint).not.toContain(t().menu.hintOpen); }); - it('does not offer move or open actions on an error screen', async () => { + it('says the calendar looks offline and retries from the error screen', async () => { loadCalendarOrCacheMock.mockRejectedValueOnce(new Error('Broke')); - await eventsView.load(fakeCtx()); - const hint = stripAnsi(eventsView.footerHint(5, 80) ?? ''); - expect(hint).toContain('1-5'); - expect(hint).not.toContain(t().menu.hintMove); - expect(hint).not.toContain(t().menu.hintOpen); + const ctx = fakeCtx(); + await eventsView.load(ctx); + const out = stripAnsi(eventsView.render(ctx).join('\n')); + expect(out).toContain(t().calendar.offlineError); + expect(out).toContain(t().calendar.errorHint); + expect(out).toContain(t().calendar.retry); + expect(eventsView.capturesPageKeys()).toBe(true); + + eventsView.handleKey('\r', ctx); + await Promise.resolve(); + expect(loadCalendarOrCacheMock).toHaveBeenCalledTimes(2); }); }); @@ -273,6 +279,56 @@ describe('eventsView navigation', () => { expect(out).not.toContain(t().calendar.noEvents); }); + it('marks and dims the events that already ended in this week', async () => { + calendarInRange.mockReturnValue([ + event('Monday talk', '2026-07-13T10:00:00', '2026-07-13T11:00:00'), + event('Friday lab', '2026-07-17T10:00:00'), + ]); + const ctx = fakeCtx(); + await eventsView.load(ctx); + press(ctx, DOWN, '\r'); + + const lines = screen(ctx); + expect(lines.find((line) => line.includes('Monday talk'))).toContain(t().calendar.endedLabel); + expect(lines.find((line) => line.includes('Friday lab'))).not.toContain( + t().calendar.endedLabel, + ); + }); + + it('shows the start and end time on the detail screen', async () => { + calendarInRange.mockReturnValue([event('Talk', '2026-07-16T19:00:00', '2026-07-16T21:00:00')]); + const ctx = fakeCtx(); + await eventsView.load(ctx); + press(ctx, '\r', '\r'); + + expect(screen(ctx).join('\n')).toContain('07-16 19:00–21:00'); + }); + + it('keeps the offline notice on every screen while showing a stored calendar', async () => { + calendarInRange.mockReturnValue([event('Talk', '2026-07-16T19:00:00')]); + loadCalendarOrCacheMock.mockResolvedValueOnce({ + calendar: { inRange: calendarInRange, heatmap: calendarHeatmap }, + stale: true, + }); + const ctx = fakeCtx(); + await eventsView.load(ctx); + press(ctx, '\r'); + expect(screen(ctx).join('\n')).toContain(t().common.offline); + press(ctx, '\r'); + expect(screen(ctx).join('\n')).toContain(t().common.offline); + }); + + it('offers the activity item only when the hub has no room for the heatmap', async () => { + const ctx = fakeCtx(); + await eventsView.load(ctx); + expect(screen(ctx).join('\n')).toContain(t().calendar.heatmap.title); + + const tall = { ...ctx, size: { rows: 45, cols: 160 }, bodyRows: 40 }; + const lines = screen(tall); + expect(lines.filter((line) => line.includes(t().calendar.heatmap.title))).toHaveLength(1); + expect(lines.some((line) => line.includes(t().calendar.heatmap.legendLess))).toBe(true); + }); + it('returns from a detail to the same list title and cursor', async () => { calendarInRange.mockReturnValue([ event('First', '2026-07-16T10:00:00'), diff --git a/src/app/views/events.ts b/src/app/views/events.ts index 461e643..6ef4c14 100644 --- a/src/app/views/events.ts +++ b/src/app/views/events.ts @@ -3,13 +3,14 @@ import type { AppContext, View } from '../view.js'; import { captureFooterHint, passiveFooterHint } from '../chrome.js'; import { ListField, computeMaxVisible } from '../fields/list-field.js'; import { TextField } from '../fields/text-field.js'; -import { renderEvents, type EventsViewState } from './events-render.js'; +import { hubShowsHeatmap, renderEvents, type EventsViewState } from './events-render.js'; import { setVimKeysActive } from '../../core/vim-keys.js'; import { pickIcon } from '../../core/icons.js'; import { padEndV, visualWidth } from '../../core/text.js'; import type { MenuOption } from '../../core/components/menu.js'; import { fmt, t } from '../../i18n/index.js'; import { + eventTimeRange, eventWhen, exportEventIcs, loadCalendarOrCache, @@ -19,6 +20,7 @@ import { } from '../../features/calendar.js'; import { currentEvents, + hasEnded, monthRange, pastEvents, searchEvents, @@ -31,12 +33,13 @@ let stale = false; let currentList: CalendarEvent[] = []; let listState: EventsViewState | undefined; let hubField: ListField | undefined; +let hubValues: string[] = []; function backLabel(): string { return t().common.back; } -function buildHubField(initialIndex: number): ListField { +function buildHubField(selected: string | undefined, withHeatmap: boolean): ListField { const trans = t(); const options = [ { value: 'upcoming', label: trans.calendar.next30Days }, @@ -44,17 +47,31 @@ function buildHubField(initialIndex: number): ListField { { value: 'month', label: trans.calendar.thisMonth }, { value: 'search', label: trans.calendar.search }, { value: 'past', label: trans.calendar.pastEvents }, - { value: 'heatmap', label: trans.calendar.heatmap.title }, + ...(withHeatmap ? [{ value: 'heatmap', label: trans.calendar.heatmap.title }] : []), ]; + hubValues = options.map((option) => option.value); + const initialIndex = Math.max(0, selected === undefined ? 0 : hubValues.indexOf(selected)); return new ListField({ title: trans.calendar.browse, options, initialIndex }); } +function selectedHubValue(): string | undefined { + return hubField ? hubValues[hubField.selectedIndex] : undefined; +} + +function syncHubField(bodyRows: number): void { + if (state.mode !== 'hub') return; + const withHeatmap = !hubShowsHeatmap(bodyRows, state.heatmapBuckets); + if (hubValues.includes('heatmap') === withHeatmap) return; + hubField = buildHubField(selectedHubValue(), withHeatmap); + state = { ...state, hubField }; +} + function showList( ctx: AppContext, title: string, events: CalendarEvent[], emptyMessage: string, - endedFrom = events.length, + isEnded: (event: CalendarEvent) => boolean = () => false, ): void { const trans = t(); const dot = pickIcon('·', '-'); @@ -62,7 +79,8 @@ function showList( const whenWidth = Math.max(0, ...display.map((event) => visualWidth(eventWhen(event)))); const options: MenuOption[] = [ ...display.map((event, index) => { - const ended = index >= endedFrom; + const raw = events[index]; + const ended = raw !== undefined && isEnded(raw); return { value: String(index), label: `${padEndV(eventWhen(event), whenWidth)} ${event.title}${recurringMark(event)}`, @@ -92,11 +110,11 @@ function goToHub(): void { const now = new Date(); const upcoming = calendar ? currentEvents(calendar, now) : []; const nextEvent = upcoming.find((event) => event.start >= now); - hubField = buildHubField(hubField?.selectedIndex ?? 0); + const withHeatmap = hubValues.length === 0 || hubValues.includes('heatmap'); + hubField = buildHubField(selectedHubValue(), withHeatmap); state = { mode: 'hub', hubField, - ...(stale ? { stale } : {}), ...(nextEvent === undefined ? {} : { nextEvent: toDisplayEvent(nextEvent) }), heatmapBuckets: calendar ? yearHeatmap(calendar, now) : [], recentEvents: upcoming.slice(0, RECENT_EVENTS_CAP).map(toDisplayEvent), @@ -111,7 +129,7 @@ function showDetail(raw: CalendarEvent): void { state = { mode: 'detail', detailTitle: e.title, - detailMeta: `${e.date}${e.time ? ' ' + e.time : ''} ${dot} ${e.location}${raw.recurring ? ` ${dot} ${trans.calendar.recurringLabel}` : ''}`, + detailMeta: `${eventTimeRange(raw)} ${dot} ${e.location}${raw.recurring ? ` ${dot} ${trans.calendar.recurringLabel}` : ''}`, detailDescription: e.description, detailEvent: raw, detailField: new ListField({ @@ -132,6 +150,7 @@ export const eventsView = { if (ctx.signal?.aborted) return; state = { mode: 'loading' }; hubField = undefined; + hubValues = []; ctx.rerender(); try { const loaded = await loadCalendarOrCache(ctx.signal); @@ -141,14 +160,26 @@ export const eventsView = { goToHub(); } catch { if (ctx.signal?.aborted) return; - state = { mode: 'error', errorMessage: t().calendar.error }; + state = { + mode: 'error', + errorMessage: t().calendar.offlineError, + errorField: new ListField({ + options: [{ value: 'retry', label: t().calendar.retry }], + }), + }; } if (!ctx.signal?.aborted) ctx.rerender(); }, render(ctx: AppContext): string[] { state.listField?.setMaxVisible(computeMaxVisible(ctx.bodyRows)); - return renderEvents(state, new Date(), ctx.bodyRows, ctx.size.cols); + syncHubField(ctx.bodyRows); + return renderEvents( + stale ? { ...state, stale } : state, + new Date(), + ctx.bodyRows, + ctx.size.cols, + ); }, isBusy(): boolean { @@ -160,12 +191,17 @@ export const eventsView = { }, capturesPageKeys(): boolean { - return state.mode === 'hub' || state.mode === 'list' || state.mode === 'detail'; + return ( + state.mode === 'hub' || + state.mode === 'list' || + state.mode === 'detail' || + state.mode === 'error' + ); }, footerHint(tabCount: number, cols = Number.POSITIVE_INFINITY): string | undefined { if (state.mode === 'search') return captureFooterHint(cols); - const passive = state.mode === 'loading' || state.mode === 'error' || state.mode === 'heatmap'; + const passive = state.mode === 'loading' || state.mode === 'heatmap'; return passive ? passiveFooterHint(tabCount, cols) : undefined; }, @@ -188,6 +224,10 @@ export const eventsView = { }, handleKey(key: string, ctx: AppContext): void { + if (state.mode === 'error') { + if (state.errorField?.handleKey(key).selected === 'retry') void eventsView.load(ctx); + return; + } if (!calendar) return; switch (state.mode) { case 'hub': { @@ -208,7 +248,13 @@ export const eventsView = { const r = result.selected === 'week' ? weekRange(now) : monthRange(now); const title = result.selected === 'week' ? trans.calendar.thisWeek : trans.calendar.thisMonth; - showList(ctx, title, calendar.inRange(r.start, r.end), trans.calendar.noEventsInRange); + showList( + ctx, + title, + calendar.inRange(r.start, r.end), + trans.calendar.noEventsInRange, + (event) => hasEnded(event, now), + ); return; } if (result.selected === 'past') { @@ -284,20 +330,20 @@ export const eventsView = { goToHub(); return; } - const { upcoming, past } = searchEvents(calendar, query, new Date()); + const now = new Date(); + const { upcoming, past } = searchEvents(calendar, query, now); const trans = t(); showList( ctx, `${trans.calendar.search}: ${query}`, [...upcoming, ...past], fmt(trans.calendar.searchNoResultsFor, { query }), - upcoming.length, + (event) => hasEnded(event, now), ); } return; } case 'loading': - case 'error': return; } }, diff --git a/src/core/components/menu.test.ts b/src/core/components/menu.test.ts index c37415e..07be8c9 100644 --- a/src/core/components/menu.test.ts +++ b/src/core/components/menu.test.ts @@ -184,6 +184,34 @@ describe('renderMenu with a hint column', () => { expect(text.replace(/\s/g, '')).toContain('EveningtalkonRustownership'); }); + it('keeps a summary column beside short labels instead of dropping it', () => { + const docs = [ + { + value: 'a', + label: 'Join', + hint: 'Anyone curious about computers is welcome here, students or not', + }, + { + value: 'b', + label: 'Infrastructure', + hint: 'Servers, serverless functions and public repos', + }, + ].map((option) => ({ ...option, hintColumn: true })); + const lines = renderMenu({ options: docs, selectedIndex: 0 }, 60).split('\n').map(stripAnsi); + expect(lines).toHaveLength(2); + expect(lines.every((line) => visualWidth(line) <= 60)).toBe(true); + expect(lines[0]?.indexOf('Anyone')).toBe(lines[1]?.indexOf('Servers')); + expect(lines[0]).toContain('…'); + }); + + it('drops a summary column that would be too narrow to read', () => { + const docs = [ + { value: 'a', label: 'Infrastructure and projects', hint: 'Servers, serverless functions' }, + ].map((option) => ({ ...option, hintColumn: true })); + const text = renderMenu({ options: docs, selectedIndex: 0 }, 44); + expect(stripAnsi(text)).not.toContain('Servers'); + }); + it('dims an option marked as dim', () => { const level = chalk.level; chalk.level = 3; diff --git a/src/core/components/menu.ts b/src/core/components/menu.ts index a4caad5..e94de6a 100644 --- a/src/core/components/menu.ts +++ b/src/core/components/menu.ts @@ -112,6 +112,7 @@ function renderIndentedText( } const MIN_LABEL_COLUMN = 24; +const MIN_HINT_COLUMN = 16; function clipWithEllipsis(value: string, width: number): string { if (visualWidth(value) <= width) return value; @@ -126,7 +127,10 @@ function fitColumns(columns: MenuColumns, contentWidth: number): MenuColumns | u Math.max(contentWidth - 2 - columns.label, Math.ceil(contentWidth / 3)), ); const label = Math.min(columns.label, contentWidth - 2 - hint); - return label >= MIN_LABEL_COLUMN ? { label, hint } : undefined; + const readable = + label >= Math.min(MIN_LABEL_COLUMN, columns.label) && + hint >= Math.min(MIN_HINT_COLUMN, columns.hint); + return readable ? { label, hint } : undefined; } function optionCells( diff --git a/src/core/text.test.ts b/src/core/text.test.ts index 15cd1c9..f0fdac0 100644 --- a/src/core/text.test.ts +++ b/src/core/text.test.ts @@ -97,6 +97,12 @@ describe('clipAnsiToVisualWidth', () => { }); describe('wrapAnsiToVisualWidth', () => { + it('never ends a line with an opening quote or starts one with a closing quote', () => { + const lines = wrapAnsiToVisualWidth('一二三四五六七“八九”十一二三四五', 16); + expect(lines.every((line) => !line.endsWith('“') && !line.startsWith('”'))).toBe(true); + expect(lines.join('')).toBe('一二三四五六七“八九”十一二三四五'); + }); + it('preserves styled Chinese text across wrapped lines', () => { const source = '中文正文不会被截断'; const lines = wrapAnsiToVisualWidth(`\x1b[36m${source}\x1b[39m`, 6); diff --git a/src/core/text.ts b/src/core/text.ts index 334e393..516bd76 100644 --- a/src/core/text.ts +++ b/src/core/text.ts @@ -241,8 +241,8 @@ function renderWrappedSegment( return osc8AtEnd ? `${withReset}${OSC8_CLOSE}` : withReset; } -const NO_LINE_START = /^[。.、,;:?!)]}〉》」』】〕・ー~…‥%‰°℃,.;:?!)\]}>]$/u; -const NO_LINE_END = /^[([{〈《「『【〔([{<]$/u; +const NO_LINE_START = /^[。.、,;:?!)]}〉》」』】〕・ー~…‥%‰°℃,.;:?!)\]}>”’]$/u; +const NO_LINE_END = /^[([{〈《「『【〔([{<“‘]$/u; function visibleAt(tokens: readonly WrapToken[], index: number): WrapToken | undefined { const token = tokens[index]; diff --git a/src/features/calendar-heatmap.test.ts b/src/features/calendar-heatmap.test.ts index 4037b9e..ba71719 100644 --- a/src/features/calendar-heatmap.test.ts +++ b/src/features/calendar-heatmap.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeAll } from 'vitest'; +import chalk from 'chalk'; import type { HeatmapBucket } from '@nbtca/nbtcal'; import { renderHeatmap } from './calendar-heatmap.js'; import { setLanguage } from '../i18n/index.js'; @@ -106,7 +107,7 @@ describe('renderHeatmap', () => { expect(firstGridRow.startsWith(' ')).toBe(true); const firstNonSpace = (s: string) => s.search(/\S/); - expect(firstNonSpace(monthLine)).toBe(6); + expect((firstNonSpace(monthLine) - 6) % 2).toBe(0); expect(firstNonSpace(secondGridRow)).toBe(6); expect(firstNonSpace(firstGridRow)).toBe(3); }); @@ -117,20 +118,65 @@ describe('renderHeatmap', () => { expect(visualWidth(stripAnsi(lines[2] ?? ''))).toBe(59); }); - it('keeps Chinese month labels on their week columns', () => { + function monthLabels(cols: number): string[] { + return stripAnsi(renderHeatmap(buckets, today, { color: false, cols }).split('\n')[2] ?? '') + .trim() + .split(/\s+/); + } + + it('labels every month in order at every width, never letting two labels touch', () => { + for (const language of ['en', 'zh'] as const) { + setLanguage(language); + try { + for (const cols of [40, 60, 80, 100, 114, 160]) { + const labels = monthLabels(cols); + const months = labels.map((label) => { + const number = Number.parseInt(label, 10); + return Number.isNaN(number) ? new Date(`${label} 1, 2025`).getMonth() + 1 : number; + }); + months.slice(1).forEach((month, index) => { + expect(month).toBe(((months[index] ?? 0) % 12) + 1); + }); + expect(months.at(-1)).toBe(6); + } + } finally { + setLanguage('en'); + } + } + }); + + it('keeps full month names when the weeks are wide enough', () => { setLanguage('zh'); try { - const lines = renderHeatmap(buckets, today, { color: false, cols: 80 }) - .split('\n') - .map(stripAnsi); - const monthRow = lines[2] ?? ''; - const gridRow = lines[3] ?? ''; - expect(visualWidth(monthRow)).toBeLessThanOrEqual(visualWidth(gridRow)); - expect(monthRow).not.toMatch(/月\d/); - expect(monthRow).toContain('6月'); + const labels = monthLabels(114); + expect(labels).toHaveLength(12); + expect(labels.every((label) => label.endsWith('月'))).toBe(true); } finally { setLanguage('en'); } + expect(monthLabels(100)).toContain('Oct'); + }); + + it('drops a partial first month rather than crowding the next label', () => { + const line = stripAnsi( + renderHeatmap(buckets, today, { color: false, cols: 114 }).split('\n')[2] ?? '', + ); + expect(line.trim().split(/\s+/)[0]).toBe('Jul'); + }); + + it('keeps empty days faint and every active level in one theme-aware color', () => { + const level = chalk.level; + chalk.level = 1; + try { + const output = renderHeatmap(makeBuckets('2025-06-09', [0, 1, 2, 3, 4]), today, { + color: true, + }); + expect(output).toContain(chalk.dim('·')); + for (const glyph of ['░', '▒', '▓', '█']) expect(output).toContain(chalk.green(glyph)); + expect(output).not.toMatch(/\u001b\[38;[25];/); + } finally { + chalk.level = level; + } }); it('reduces the visible week window to fit a 40-column terminal', () => { diff --git a/src/features/calendar-heatmap.ts b/src/features/calendar-heatmap.ts index 08ff640..1995df5 100644 --- a/src/features/calendar-heatmap.ts +++ b/src/features/calendar-heatmap.ts @@ -1,7 +1,6 @@ -import chalk from 'chalk'; import type { HeatmapBucket } from '@nbtca/nbtcal'; import { pickIcon } from '../core/icons.js'; -import { space, type } from '../core/theme.js'; +import { c, space, type } from '../core/theme.js'; import { t, getCurrentLanguage } from '../i18n/index.js'; import { visualWidth } from '../core/text.js'; @@ -17,23 +16,49 @@ function utcDayToMonIndex(utcDay: number): number { return (utcDay + 6) % 7; } -function countToGlyph(count: number): string { - if (count <= 0) return pickIcon('·', ' '); - if (count === 1) return pickIcon('░', '.'); - if (count === 2) return pickIcon('▒', ':'); +const LEVEL_GLYPHS = [ + ['·', ' '], + ['░', '.'], + ['▒', ':'], + ['▓', '-'], + ['█', '='], +] as const; +const MAX_WEEK_COLUMNS = 53; +const GRID_PREFIX_WIDTH = 6; - if (count === 3) return pickIcon('▓', '-'); - return pickIcon('█', '='); +function levelCell(count: number, useColor: boolean): string { + const level = Math.max(0, Math.min(count, LEVEL_GLYPHS.length - 1)); + const [unicode, ascii] = LEVEL_GLYPHS[level] ?? LEVEL_GLYPHS[0]; + const cell = pickIcon(unicode, ascii); + if (!useColor) return cell; + return level === 0 ? type.hint(cell) : c.success(cell); } -const HEATMAP_RAMP = ['#1b4332', '#2d6a4f', '#40916c', '#52b788'] as const; -const MAX_WEEK_COLUMNS = 53; -const GRID_PREFIX_WIDTH = 6; +interface MonthAnchor { + start: number; + date: Date; +} -function applyColor(glyph: string, count: number, useColor: boolean): string { - if (!useColor || count <= 0) return glyph; - const level = Math.min(count, HEATMAP_RAMP.length) - 1; - return chalk.hex(HEATMAP_RAMP[level] as string)(glyph); +function placeMonthLabels( + anchors: readonly MonthAnchor[], + width: number, + format: (date: Date) => string, + strict: boolean, +): string | undefined { + let line = ''; + for (const [index, anchor] of anchors.entries()) { + const label = format(anchor.date); + const end = anchor.start + visualWidth(label); + const used = visualWidth(line); + const gap = index === 0 ? 2 : 1; + const limit = (anchors[index + 1]?.start ?? width + gap) - gap; + if (anchor.start >= used + (used > 0 ? 1 : 0) && end <= limit) { + line += ' '.repeat(anchor.start - used) + label; + } else if (strict && index > 0) { + return undefined; + } + } + return line; } export function renderHeatmap( @@ -94,29 +119,22 @@ export function renderHeatmap( timeZone: 'UTC', }); const cellsWidth = numCols * cellWidth; - let monthLine = ''; + const anchors: MonthAnchor[] = []; let prevMonth = -1; - for (let col = 0; col < numCols; col++) { - let labelDate: Date | null = null; - for (let row = 0; row < 7; row++) { - const cell = columns[col]?.[row]; - if (cell !== null && cell !== undefined) { - labelDate = parseBucketDate(cell.date); - break; - } - } - if (labelDate === null) continue; - const month = labelDate.getUTCMonth(); - if (month !== prevMonth) { - prevMonth = month; - const label = monthFmt.format(labelDate); - const start = col * cellWidth; - const used = visualWidth(monthLine); - if (start >= used + (used > 0 ? 1 : 0) && start + visualWidth(label) <= cellsWidth) { - monthLine += ' '.repeat(start - used) + label; - } - } + for (const [col, column] of columns.entries()) { + const cell = column.find((candidate) => candidate !== null); + if (!cell) continue; + const date = parseBucketDate(cell.date); + if (date.getUTCMonth() === prevMonth) continue; + prevMonth = date.getUTCMonth(); + anchors.push({ start: col * cellWidth, date }); } + const monthNumber = (date: Date) => String(date.getUTCMonth() + 1); + const monthLine = + placeMonthLabels(anchors, cellsWidth, (date) => monthFmt.format(date), true) ?? + placeMonthLabels(anchors, cellsWidth, monthNumber, true) ?? + placeMonthLabels(anchors, cellsWidth, monthNumber, false) ?? + ''; const monthLabelLine = space.indent + weekdayLabel + monthLine; const weekdayNames = [ @@ -141,32 +159,16 @@ export function renderHeatmap( const cells = columns.map((col) => { const cell = col[row]; if (cell === null || cell === undefined) return ' '; - const glyph = countToGlyph(cell.count); - return applyColor(glyph, cell.count, useColor); + return levelCell(cell.count, useColor); }); lines.push(`${space.indent}${wdLabel} ${cells.join(cellWidth === 2 ? ' ' : '')}`); } - const legendGlyphs = [ - pickIcon('·', ' '), - pickIcon('░', '.'), - pickIcon('▒', ':'), - pickIcon('▓', '-'), - pickIcon('█', '='), - ]; - const legendColored = useColor - ? [ - legendGlyphs[0] ?? '·', - applyColor(legendGlyphs[1] ?? '░', 1, true), - applyColor(legendGlyphs[2] ?? '▒', 2, true), - applyColor(legendGlyphs[3] ?? '▓', 3, true), - applyColor(legendGlyphs[4] ?? '█', 4, true), - ] - : legendGlyphs; + const legend = LEVEL_GLYPHS.map((_, level) => levelCell(level, useColor)).join(''); lines.push(''); lines.push( - `${space.indent}${type.hint(trans.calendar.heatmap.legendLess)} ${legendColored.join('')} ${type.hint(trans.calendar.heatmap.legendMore)}`, + `${space.indent}${type.hint(trans.calendar.heatmap.legendLess)} ${legend} ${type.hint(trans.calendar.heatmap.legendMore)}`, ); return lines.join('\n'); diff --git a/src/features/calendar-query.ts b/src/features/calendar-query.ts index 120e813..db1d363 100644 --- a/src/features/calendar-query.ts +++ b/src/features/calendar-query.ts @@ -30,7 +30,7 @@ export function eventEnd(event: CalendarEvent): Date { ); } -function hasEnded(event: CalendarEvent, now: Date): boolean { +export function hasEnded(event: CalendarEvent, now: Date): boolean { return event.start < now && eventEnd(event) <= now; } diff --git a/src/features/calendar.test.ts b/src/features/calendar.test.ts index 138d2ec..6513620 100644 --- a/src/features/calendar.test.ts +++ b/src/features/calendar.test.ts @@ -7,6 +7,7 @@ import { renderEventBrief, renderEventBriefLines, exportEventIcs, + eventTimeRange, } from './calendar.js'; import chalk from 'chalk'; import { setLanguage } from '../i18n/index.js'; @@ -313,7 +314,7 @@ describe('renderCountdownBanner', () => { expect(text).toContain(title.replace(/\s/g, '')); expect(text).toContain('Next'); expect(text).toContain('1d0h'); - expect(lines.filter((line) => /[→>]/u.test(stripAnsi(line)))).toHaveLength(1); + expect(lines.filter((line) => /[●*]/u.test(stripAnsi(line)))).toHaveLength(1); }); it('spells the countdown out in Chinese', () => { setLanguage('zh'); @@ -394,3 +395,40 @@ describe('exportEventIcs', () => { } }); }); + +describe('eventTimeRange', () => { + beforeAll(() => { + process.env['NBTCA_ICON_MODE'] = 'unicode'; + resetIconCache(); + }); + + const year = new Date().getFullYear(); + const at = (value: string) => new Date(`${year}-${value}`); + + it('joins a same-day end time to the start', () => { + const event = makeEvent({ start: at('06-17T19:00:00'), end: at('06-17T21:30:00') }); + expect(eventTimeRange(event)).toBe('06-17 19:00–21:30'); + }); + + it('spells out the end date when an event runs past midnight', () => { + const event = makeEvent({ start: at('06-17T22:00:00'), end: at('06-18T02:00:00') }); + expect(eventTimeRange(event)).toBe('06-17 22:00 – 06-18 02:00'); + }); + + it('shows only the start when there is no later end', () => { + expect(eventTimeRange(makeEvent({ start: at('06-17T19:00:00'), end: null }))).toBe( + '06-17 19:00', + ); + }); + + it('treats an all-day end date as exclusive', () => { + const oneDay = makeEvent({ isAllDay: true, start: at('06-17T00:00:00'), end: null }); + const threeDays = makeEvent({ + isAllDay: true, + start: at('06-17T00:00:00'), + end: at('06-20T00:00:00'), + }); + expect(eventTimeRange(oneDay)).toBe('06-17'); + expect(eventTimeRange(threeDays)).toBe('06-17 – 06-19'); + }); +}); diff --git a/src/features/calendar.ts b/src/features/calendar.ts index 03824c3..63729a5 100644 --- a/src/features/calendar.ts +++ b/src/features/calendar.ts @@ -23,6 +23,7 @@ import { t } from '../i18n/index.js'; import { localDayDifference } from '../core/calendar-day.js'; import { countdownParts, + eventEnd, isCountdownUrgent, buildExportFilename, formatDuration, @@ -138,6 +139,22 @@ export function toDisplayEvent(e: CalendarEvent): Event { }; } +export function eventTimeRange(e: CalendarEvent): string { + const dash = pickIcon('–', '-'); + const startDate = formatDate(e.start); + if (e.isAllDay) { + const lastDay = new Date(eventEnd(e).getTime() - 1); + const endDate = formatDate(lastDay); + return endDate === startDate ? startDate : `${startDate} ${dash} ${endDate}`; + } + const start = `${startDate} ${formatTime(e.start)}`; + if (!e.end || e.end <= e.start) return start; + const endDate = formatDate(e.end); + return endDate === startDate + ? `${start}${dash}${formatTime(e.end)}` + : `${start} ${dash} ${endDate} ${formatTime(e.end)}`; +} + export async function loadCalendarOrCache( signal?: AbortSignal, ): Promise<{ calendar: Calendar; stale: boolean }> { @@ -311,7 +328,7 @@ export function renderCountdownBanner( const whenStyled = isCountdownUrgent(p) ? c.warn(when) : type.hint(when); const dot = pickIcon('·', '-'); const content = `${type.label(trans.calendar.next)} ${dot} ${type.body(event.title)} ${dot} ${whenStyled}`; - return hangingLines(type.active(glyph.cursor()), content, cols).join('\n'); + return hangingLines(type.active(glyph.dot()), content, cols).join('\n'); } export function exportEventIcs( diff --git a/src/features/docs-markdown.test.ts b/src/features/docs-markdown.test.ts index 789d807..31a60f3 100644 --- a/src/features/docs-markdown.test.ts +++ b/src/features/docs-markdown.test.ts @@ -37,10 +37,10 @@ describe('links', () => { expect(out).not.toContain('#沿革'); }); - it('keeps the target of external links, once', () => { - expect(render('见 [学校官网](https://www.nbt.edu.cn) 。').join('\n')).toContain( - '学校官网 (https://www.nbt.edu.cn)', - ); + it('shows only the text of an external link and leaves its URL to the link picker', () => { + const out = render('见 [学校官网](https://www.nbt.edu.cn) 。').join('\n'); + expect(out).toContain('学校官网'); + expect(out).not.toContain('nbt.edu.cn'); expect(render('').join('\n')).toBe('https://example.com'); expect(render('[github.com/nbtca](https://github.com/nbtca/)').join('\n')).toBe( 'github.com/nbtca', @@ -171,6 +171,6 @@ describe('plain output', () => { ].join('\n\n'); const out = renderMarkdown(marked.lexer(source), 60).join('\n'); expect(out).not.toMatch(/[\u001B\u009B\u009D]/u); - expect(out).toContain('│ Note: read this (https://example.com) and code'); + expect(out).toContain('│ Note: read this and code'); }); }); diff --git a/src/features/docs-markdown.ts b/src/features/docs-markdown.ts index 9fbebe8..5e8f44a 100644 --- a/src/features/docs-markdown.ts +++ b/src/features/docs-markdown.ts @@ -42,40 +42,32 @@ function joinSoftBreaks(text: string): string { return text.replace(CJK_SOFT_BREAK, '').replace(/[ \t]*\n[ \t]*/g, ' '); } -function renderInline(tokens: readonly Token[] | undefined, hrefs: boolean): string { - return (tokens ?? []).map((token) => renderInlineToken(token as MarkedToken, hrefs)).join(''); +function renderInline(tokens: readonly Token[] | undefined): string { + return (tokens ?? []).map((token) => renderInlineToken(token as MarkedToken)).join(''); } -function renderLink(token: Tokens.Link, hrefs: boolean): string { - const text = renderInline(token.tokens, hrefs); - const bare = (href: string) => href.replace(/^(?:https?:\/\/|mailto:)/, '').replace(/\/$/, ''); - const selfLabelled = bare(stripAnsi(text)) === bare(token.href); - if (isInternalHref(token.href) || token.href.startsWith('#') || selfLabelled) { - return chalk.underline(c.accent(text)); - } - return hrefs ? `${c.accent(text)} ${c.url(`(${token.href})`)}` : c.accent(text); +function renderLink(token: Tokens.Link): string { + return chalk.underline(c.accent(renderInline(token.tokens))); } -function renderInlineToken(token: MarkedToken, hrefs: boolean): string { +function renderInlineToken(token: MarkedToken): string { switch (token.type) { case 'text': - return token.tokens - ? renderInline(token.tokens, hrefs) - : joinSoftBreaks(decodeEntities(token.text)); + return token.tokens ? renderInline(token.tokens) : joinSoftBreaks(decodeEntities(token.text)); case 'escape': return token.text; case 'strong': - return chalk.bold(renderInline(token.tokens, hrefs)); + return chalk.bold(renderInline(token.tokens)); case 'em': - return chalk.italic(renderInline(token.tokens, hrefs)); + return chalk.italic(renderInline(token.tokens)); case 'del': - return chalk.strikethrough(c.muted(renderInline(token.tokens, hrefs))); + return chalk.strikethrough(c.muted(renderInline(token.tokens))); case 'codespan': return c.code(token.text); case 'br': return '\n'; case 'link': - return renderLink(token, hrefs); + return renderLink(token); case 'image': return `${pickIcon('🖼️', '[image]')} ${token.text || 'image'}`; case 'html': @@ -112,7 +104,7 @@ function styleHeading(text: string, depth: number): string { } function renderHeading(token: Tokens.Heading, width: number): string[] { - const lines = wrap(styleHeading(renderInline(token.tokens, false), token.depth), width); + const lines = wrap(styleHeading(renderInline(token.tokens), token.depth), width); if (token.depth !== 2) return lines; const underline = Math.min(width, Math.max(...lines.map(visualWidth))); return [...lines, c.muted(glyph.rule().repeat(underline))]; @@ -209,8 +201,8 @@ function renderRecords( } function renderTable(token: Tokens.Table, width: number): string[] { - const header = token.header.map((cell) => renderInline(cell.tokens, false)); - const rows = token.rows.map((row) => row.map((cell) => renderInline(cell.tokens, false))); + const header = token.header.map((cell) => renderInline(cell.tokens)); + const rows = token.rows.map((row) => row.map((cell) => renderInline(cell.tokens))); const natural = header.map((label, column) => Math.max(cellWidth(label), ...rows.map((row) => cellWidth(row[column] ?? ''))), ); @@ -263,9 +255,9 @@ function renderBlock(token: MarkedToken, width: number, depth: number): string[] case 'heading': return renderHeading(token, width); case 'paragraph': - return wrap(renderInline(token.tokens, true), width); + return wrap(renderInline(token.tokens), width); case 'text': - return wrap(renderInlineToken(token, true), width); + return wrap(renderInlineToken(token), width); case 'code': return renderCode(token, width); case 'blockquote': @@ -292,7 +284,7 @@ function renderBlock(token: MarkedToken, width: number, depth: number): string[] case 'image': case 'link': case 'strong': - return wrap(renderInlineToken(token, true), width); + return wrap(renderInlineToken(token), width); } } diff --git a/src/features/docs.test.ts b/src/features/docs.test.ts index 939f076..3476ed2 100644 --- a/src/features/docs.test.ts +++ b/src/features/docs.test.ts @@ -17,6 +17,7 @@ import { loadDocForReader, fetchAllDocs, peekListedDocs, + searchDocuments, } from './docs.js'; import { setLanguage } from '../i18n/index.js'; import { stripAnsi } from '../core/text.js'; @@ -338,14 +339,14 @@ describe('peekListedDocs', () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it('keeps unstored documents as they are and reports the list incomplete', () => { + it('leaves unstored titles blank instead of guessing from the filename', () => { const listed = peekListedDocs([ stored('cached.md', 'b'.repeat(40), '# Cached\n'), stored('missing.md', 'c'.repeat(40)), ]); expect(listed.complete).toBe(false); expect(listed.docs).toMatchObject([{ title: 'Cached' }, { name: 'missing.md' }]); - expect(listed.docs[1]).not.toHaveProperty('title'); + expect(listed.docs[1]?.title).toBe(''); expect(peekListedDocs([{ name: 'x.md', path: 'guide/x.md', type: 'file' }]).complete).toBe( false, ); @@ -469,3 +470,57 @@ describe('document rendering', () => { } }); }); + +describe('reader links', () => { + it('lists external links with their URL beside internal ones', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + text: () => + Promise.resolve( + 'See [join](./join.md), [the site](https://nbtca.space) and [top](#top).', + ), + }), + ); + const doc = await loadDocForReader('about/index.md'); + expect(doc.links).toEqual([ + { text: 'join', href: 'about/join.md' }, + { text: 'the site', href: 'https://nbtca.space', external: true }, + ]); + }); +}); + +describe('searchDocuments', () => { + it('reports how many documents it has checked and drops the title from each excerpt', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: string) => + Promise.resolve( + input.includes('/trees/') + ? Response.json({ + tree: [ + { path: 'guide/alpha.md', type: 'blob' }, + { path: 'guide/beta.md', type: 'blob' }, + ], + truncated: false, + }) + : new Response( + input.endsWith('alpha.md') + ? '# Alpha Guide\n\nThe needle is here.' + : '# Beta\n\nNothing to see.', + ), + ), + ), + ); + const progress: [number, number][] = []; + const results = await searchDocuments('needle', undefined, (done, total) => { + progress.push([done, total]); + }); + expect(progress.at(0)).toEqual([0, 2]); + expect(progress.at(-1)).toEqual([2, 2]); + expect(results.map((result) => result.title)).toEqual(['Alpha Guide']); + expect(results[0]?.excerpt).not.toMatch(/^Alpha Guide/); + expect(results[0]?.excerpt).toContain('needle'); + }); +}); diff --git a/src/features/docs.ts b/src/features/docs.ts index 135ec16..2e010bf 100644 --- a/src/features/docs.ts +++ b/src/features/docs.ts @@ -82,7 +82,7 @@ const RENDER_CACHE_MAX = 50; const renderCache = new Map>(); const METADATA_CACHE_TTL_MS = 10 * 60 * 1000; const METADATA_CACHE_MAX = 200; -const METADATA_CONCURRENCY = 4; +const METADATA_CONCURRENCY = 6; interface DocMetadata { title: string; @@ -570,15 +570,20 @@ function hasMermaidBlock(content: string): boolean { export interface DocLink { href: string; text: string; + external?: boolean; } -function extractInternalLinks(markdown: string): DocLink[] { +function isExternalHref(href: string): boolean { + return /^https?:\/\//i.test(href); +} + +function extractLinks(markdown: string): DocLink[] { const links: DocLink[] = []; - const re = /\[([^\]]+)\]\(([^)]+)\)/g; + const re = /\[([^\]]+)\]\(([^)\s]+)[^)]*\)/g; let m: RegExpExecArray | null; while ((m = re.exec(markdown))) { const href = m[2] ?? ''; - if (isInternalHref(href)) links.push({ text: m[1] ?? '', href }); + if (isInternalHref(href) || isExternalHref(href)) links.push({ text: m[1] ?? '', href }); } return links; } @@ -618,11 +623,14 @@ export async function loadDocForReader(filePath: string, signal?: AbortSignal): const seen = new Set(); const links: DocLink[] = []; - for (const raw of extractInternalLinks(renderedDoc.cleaned)) { - const resolved = resolveInternalHref(raw.href, filePath); - if (seen.has(resolved)) continue; - seen.add(resolved); - links.push({ text: sanitizeTerminalLine(raw.text), href: resolved }); + for (const raw of extractLinks(renderedDoc.cleaned)) { + const external = isExternalHref(raw.href); + const href = external + ? sanitizeTerminalLine(raw.href) + : resolveInternalHref(raw.href, filePath); + if (seen.has(href)) continue; + seen.add(href); + links.push({ text: sanitizeTerminalLine(raw.text), href, ...(external ? { external } : {}) }); } return { @@ -685,14 +693,19 @@ function listedDoc(item: DocItem, metadata?: DocMetadata): ListedDoc { return { ...item, name: sanitizeTerminalLine(item.name), - title: displayDocTitle(item.name, metadata?.title), + title: metadata ? displayDocTitle(item.name, metadata.title) : '', summary: sanitizeTerminalLine(metadata?.summary ?? ''), }; } +export function withFileNameTitle(doc: ListedDoc): ListedDoc { + return doc.title ? doc : { ...doc, title: displayDocTitle(doc.name) }; +} + export async function fetchDocMetadata( items: readonly DocItem[], signal?: AbortSignal, + onDoc?: (index: number, doc: ListedDoc) => void, ): Promise { const results = items.map((item) => listedDoc(item)); let nextIndex = 0; @@ -713,8 +726,9 @@ export async function fetchDocMetadata( results[index] = listedDoc(item, await loadDocMetadata(item.path, signal)); } catch (error) { if (signal?.aborted) throw error; - results[index] = listedDoc(item); + results[index] = withFileNameTitle(listedDoc(item)); } + onDoc?.(index, results[index]); } } @@ -723,15 +737,15 @@ export async function fetchDocMetadata( return results; } -export function peekListedDocs( - items: readonly T[], -): { docs: (T | ListedDoc)[]; complete: boolean } { +export function peekListedDocs(items: readonly DocItem[]): { + docs: ListedDoc[]; + complete: boolean; +} { let complete = true; const docs = items.map((item) => { const metadata = peekDocMetadata(item); - if (metadata) return listedDoc(item, metadata); - complete = false; - return item; + if (!metadata) complete = false; + return listedDoc(item, metadata); }); return { docs, complete }; } @@ -739,26 +753,46 @@ export function peekListedDocs( export async function fetchSectionMetadata( section: DocSection, signal?: AbortSignal, + onDoc?: (index: number, doc: ListedDoc) => void, ): Promise { - const files = await fetchDocMetadata(section.files, signal); + const files = await fetchDocMetadata(section.files, signal, onDoc); return { ...section, count: files.length, files }; } +function withoutLeadingTitle(excerpt: string, title: string): string { + const text = excerpt.trim(); + return title && text.startsWith(title) ? text.slice(title.length).trimStart() : text; +} + function searchDoc(result: DocsSearchResult): SearchDoc { + const title = displayDocTitle(result.name, result.title); return { name: sanitizeTerminalLine(result.name), path: result.path, type: 'file', - title: displayDocTitle(result.name, result.title), + title, summary: sanitizeTerminalLine(result.summary), - excerpt: sanitizeTerminalLine(result.excerpt), + excerpt: withoutLeadingTitle(sanitizeTerminalLine(result.excerpt), title), route: result.route, score: result.score, section: result.section, }; } -export async function searchDocuments(query: string, signal?: AbortSignal): Promise { +export async function searchDocuments( + query: string, + signal?: AbortSignal, + onProgress?: (done: number, total: number) => void, +): Promise { + if (onProgress) { + const items = await fetchAllDocs(signal); + let done = 0; + onProgress(done, items.length); + await fetchDocMetadata(items, signal, () => { + done += 1; + onProgress(done, items.length); + }); + } return ( await runDocsClientOperation(signal, (client) => client.search(query, { limit: 20 })) ).map(searchDoc); diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 9db331b..80be2e9 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -77,6 +77,8 @@ export interface Translations { noEvents: string; error: string; errorHint: string; + offlineError: string; + retry: string; eventsFound: string; dateTime: string; eventName: string; @@ -171,6 +173,11 @@ export interface Translations { readerLinksTitle: string; readerLinksHint: string; readerNoLinks: string; + offlineError: string; + offlineHint: string; + retryLoad: string; + searchResultsTitle: string; + searchProgress: string; }; links: { choose: string; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index cb9e87d..93de048 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -108,7 +108,9 @@ "days": "{days}d {hours}h", "hours": "{hours}h {minutes}m", "minutes": "{minutes}m" - } + }, + "offlineError": "Couldn't load the events calendar. You may be offline.", + "retry": "Retry" }, "docs": { "loading": "Loading documentation list...", @@ -159,7 +161,12 @@ "loadingFile": "Loading", "readerLinksTitle": "Jump to", "readerLinksHint": "links", - "readerNoLinks": "This page has no links to other docs" + "readerNoLinks": "This page has no links to other docs", + "offlineError": "Couldn't load the docs. You may be offline.", + "offlineHint": "Try again later, or read them at https://docs.nbtca.space", + "retryLoad": "Retry", + "searchResultsTitle": "Results for “{query}” · {count}", + "searchProgress": "Searching every document: {done} of {total} checked" }, "links": { "choose": "Links", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index f82c999..42ab766 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -108,7 +108,9 @@ "days": "{days} 天 {hours} 小时", "hours": "{hours} 小时 {minutes} 分钟", "minutes": "{minutes} 分钟" - } + }, + "offlineError": "无法获取活动日历,可能是网络没有连接。", + "retry": "重试" }, "docs": { "loading": "正在加载文档列表...", @@ -159,7 +161,12 @@ "loadingFile": "正在加载", "readerLinksTitle": "跳转到", "readerLinksHint": "链接", - "readerNoLinks": "本页没有指向其他文档的链接" + "readerNoLinks": "本页没有指向其他文档的链接", + "offlineError": "无法加载文档目录,可能是网络没有连接。", + "offlineHint": "请稍后重试,或者到 https://docs.nbtca.space 阅读", + "retryLoad": "重试", + "searchResultsTitle": "“{query}”的搜索结果 · {count} 篇", + "searchProgress": "正在逐篇检索文档,已检索 {done} / {total} 篇" }, "links": { "choose": "链接",