From 88762eab7a11e83961d48d5ee5664b583befb683 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Fri, 24 Jul 2026 18:46:53 -0400 Subject: [PATCH 1/4] feat(viewer): add documentTrail breadcrumb helper --- src/app/lib/trail.test.ts | 60 +++++++++++++++++++++++++++++++++++++++ src/app/lib/trail.ts | 45 +++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 src/app/lib/trail.test.ts create mode 100644 src/app/lib/trail.ts diff --git a/src/app/lib/trail.test.ts b/src/app/lib/trail.test.ts new file mode 100644 index 0000000..3b17659 --- /dev/null +++ b/src/app/lib/trail.test.ts @@ -0,0 +1,60 @@ +import { test, expect } from 'bun:test' +import { documentTrail } from './trail' + +test('single doc yields one current crumb, no separators implied', () => { + const crumbs = documentTrail({ labels: ['README.md'], maxWidth: 80 }) + expect(crumbs).toEqual([{ label: 'README.md', kind: 'current' }]) +}) + +test('two docs yield back then current', () => { + const crumbs = documentTrail({ labels: ['README.md', 'docs/guide.md'], maxWidth: 80 }) + expect(crumbs).toEqual([ + { label: 'README.md', kind: 'back' }, + { label: 'docs/guide.md', kind: 'current' }, + ]) +}) + +test('deep chain within width keeps every crumb and tags them', () => { + const crumbs = documentTrail({ + labels: ['a.md', 'b.md', 'c.md', 'd.md'], + maxWidth: 80, + }) + expect(crumbs.map(c => c.kind)).toEqual(['past', 'past', 'back', 'current']) +}) + +test('overflow collapses the middle, keeping first + last two', () => { + const labels = ['origin.md', 'one.md', 'two.md', 'three.md', 'docs/guide.md', 'api.md'] + const crumbs = documentTrail({ labels, maxWidth: 20 }) + expect(crumbs).toEqual([ + { label: 'origin.md', kind: 'past' }, + { label: '…', kind: 'ellipsis' }, + { label: 'docs/guide.md', kind: 'back' }, + { label: 'api.md', kind: 'current' }, + ]) +}) + +test('overflow never truncates the back or current crumb', () => { + const labels = ['a.md', 'b.md', 'c.md', 'back.md', 'current.md'] + const crumbs = documentTrail({ labels, maxWidth: 5 }) + const kinds = crumbs.map(c => c.kind) + expect(kinds).toContain('back') + expect(kinds).toContain('current') + expect(kinds.filter(k => k === 'ellipsis').length).toBe(1) +}) + +test('undefined labels fall back to a placeholder', () => { + const crumbs = documentTrail({ labels: [undefined, 'api.md'], maxWidth: 80 }) + expect(crumbs).toEqual([ + { label: '', kind: 'back' }, + { label: 'api.md', kind: 'current' }, + ]) +}) + +test('tag invariants hold: one current, at most one back and ellipsis', () => { + const labels = ['a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md'] + const crumbs = documentTrail({ labels, maxWidth: 10 }) + const kinds = crumbs.map(c => c.kind) + expect(kinds.filter(k => k === 'current').length).toBe(1) + expect(kinds.filter(k => k === 'back').length).toBe(1) + expect(kinds.filter(k => k === 'ellipsis').length).toBe(1) +}) diff --git a/src/app/lib/trail.ts b/src/app/lib/trail.ts new file mode 100644 index 0000000..44ab1cb --- /dev/null +++ b/src/app/lib/trail.ts @@ -0,0 +1,45 @@ +export type CrumbKind = 'current' | 'back' | 'past' | 'ellipsis' +export type Crumb = { label: string; kind: CrumbKind } + +const SEP = ' → ' +const ELLIPSIS = '…' +const UNTITLED = '' + +/** + * Maps an ordered list of document labels (origin first, current doc last) into + * tagged breadcrumb crumbs. When the chain joined by " → " exceeds `maxWidth`, + * the middle collapses to a single ellipsis crumb, always keeping the first + * crumb and the last two (the clickable `back` crumb and the `current` doc). + */ +export function documentTrail({ + labels, + maxWidth, +}: { + labels: (string | undefined)[] + maxWidth: number +}): Crumb[] { + const named = labels.map(label => label ?? UNTITLED) + if (named.length === 0) return [] + + const full = named.map((label, i): Crumb => ({ label, kind: tagAt(i, named.length) })) + if (named.length <= 3 || trailWidth(full) <= maxWidth) return full + + const first = full[0] + const back = full[full.length - 2] + const current = full[full.length - 1] + if (!first || !back || !current) return full + + return [first, { label: ELLIPSIS, kind: 'ellipsis' }, back, current] +} + +function tagAt(i: number, n: number): CrumbKind { + if (i === n - 1) return 'current' + if (i === n - 2) return 'back' + return 'past' +} + +function trailWidth(crumbs: Crumb[]): number { + const labelWidth = crumbs.reduce((sum, c) => sum + c.label.length, 0) + const sepWidth = Math.max(0, crumbs.length - 1) * SEP.length + return labelWidth + sepWidth +} From c3f16481ff6698bb25dff94406a5e195b190d5a6 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Fri, 24 Jul 2026 18:52:27 -0400 Subject: [PATCH 2/4] feat(viewer): render relative-link history as a breadcrumb trail --- src/app/App.tsx | 6 +- src/app/RenderView.tsx | 2 +- src/app/components/StickyHeader.back.test.tsx | 59 +++++++++++++------ .../components/StickyHeader.crumb.test.tsx | 1 + src/app/components/StickyHeader.tsx | 46 ++++++++++++--- src/app/lib/documentNavigation.ts | 9 ++- src/app/nav.integration.test.tsx | 20 +++---- src/app/state.ts | 4 +- 8 files changed, 103 insertions(+), 44 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index 52613ec..6a1e2a8 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -147,7 +147,7 @@ export function App({ ? foldOffset({ toc, id: lastHeadingId, fileLabel, historyDepth: nav.historyDepth }) : 0 - const backLabel = nav.backLabel + const trailLabels = nav.trailLabels useEffect(() => { if (!search?.committed || search.index < 0) return @@ -312,7 +312,7 @@ export function App({ contentMaxWidth, dir: nav.doc.dir, historyDepth: nav.historyDepth, - backLabel, + trailLabels, status, commands, }), @@ -327,7 +327,7 @@ export function App({ contentMaxWidth, nav.doc.dir, nav.historyDepth, - backLabel, + trailLabels, status, commands, ], diff --git a/src/app/RenderView.tsx b/src/app/RenderView.tsx index b050d0f..4770793 100644 --- a/src/app/RenderView.tsx +++ b/src/app/RenderView.tsx @@ -40,7 +40,7 @@ export function RenderView({ contentMaxWidth, dir: undefined, historyDepth: 0, - backLabel: undefined, + trailLabels: [], status: { kind: 'idle' }, commands, }), diff --git a/src/app/components/StickyHeader.back.test.tsx b/src/app/components/StickyHeader.back.test.tsx index 20462a7..4551b5f 100644 --- a/src/app/components/StickyHeader.back.test.tsx +++ b/src/app/components/StickyHeader.back.test.tsx @@ -18,9 +18,10 @@ function makeStub(overrides: Partial = {}): AppState { visibleHeadingIds: new Set(), contentWidth: 80, dir: undefined, - // Real Commands surface with an assertable goBack for the back-badge click test. + // Real Commands surface with an assertable goBack for the trail click test. commands: { ...createNoopCommands(), goBack: mock() }, historyDepth: 0, + trailLabels: [], contentMaxWidth: 80, status: { kind: 'idle' }, ...overrides, @@ -46,18 +47,19 @@ async function renderHeader(stub: AppState) { return { renderer, settle, captureCharFrame } } -test('back badge renders and clicking it calls goBack', async () => { - const stub = makeStub({ historyDepth: 1 }) +test('trail renders and clicking the back crumb calls goBack', async () => { + const stub = makeStub({ historyDepth: 1, trailLabels: ['README.md', 'docs/guide.md'] }) const { renderer, settle, captureCharFrame } = await renderHeader(stub) const mouse = createMockMouse(renderer) const frame = captureCharFrame() - expect(frame).toContain('Back') + expect(frame).toContain('README.md') + expect(frame).toContain('docs/guide.md') const lines = frame.split('\n') - const row = lines.findIndex(l => l.includes('Back')) + const row = lines.findIndex(l => l.includes('README.md')) expect(row).toBeGreaterThanOrEqual(0) - const col = (lines[row] ?? '').indexOf('Back') + const col = (lines[row] ?? '').indexOf('README.md') await mouse.click(col, row, MouseButtons.LEFT) await settle() @@ -67,26 +69,49 @@ test('back badge renders and clicking it calls goBack', async () => { renderer.destroy() }) -test('back affordance shows one arrow per depth level and the target filename', async () => { - const stub = makeStub({ historyDepth: 3, backLabel: 'nav/reference.md' }) +test('trail shows the whole chain joined by arrows', async () => { + const stub = makeStub({ + historyDepth: 3, + trailLabels: ['a.md', 'b.md', 'nav/reference.md', 'api.md'], + }) const { renderer, captureCharFrame } = await renderHeader(stub) const frame = captureCharFrame() - // One '‹' per navigation level, then the target document label. - expect(frame).toContain('‹‹‹ Back') - expect(frame).toContain('to nav/reference.md') + expect(frame).toContain('a.md') + expect(frame).toContain('→') + expect(frame).toContain('api.md') + + renderer.destroy() +}) + +test('clicking a past crumb does not call goBack', async () => { + const stub = makeStub({ + historyDepth: 3, + trailLabels: ['origin.md', 'mid.md', 'prev.md', 'current.md'], + }) + const { renderer, settle, captureCharFrame } = await renderHeader(stub) + const mouse = createMockMouse(renderer) + + const lines = captureCharFrame().split('\n') + const row = lines.findIndex(l => l.includes('origin.md')) + const col = (lines[row] ?? '').indexOf('origin.md') + + await mouse.click(col, row, MouseButtons.LEFT) + await settle() + + expect(stub.commands.goBack).not.toHaveBeenCalled() renderer.destroy() }) -test('right-click on the badge does not call goBack', async () => { - const stub = makeStub({ historyDepth: 1 }) +test('right-click on the back crumb does not call goBack', async () => { + const stub = makeStub({ historyDepth: 1, trailLabels: ['README.md', 'docs/guide.md'] }) const { renderer, settle, captureCharFrame } = await renderHeader(stub) const mouse = createMockMouse(renderer) const lines = captureCharFrame().split('\n') - const row = lines.findIndex(l => l.includes('Back')) - const col = (lines[row] ?? '').indexOf('Back') + const row = lines.findIndex(l => l.includes('README.md')) + const col = (lines[row] ?? '').indexOf('README.md') await mouse.click(col, row, MouseButtons.RIGHT) await settle() @@ -97,10 +122,10 @@ test('right-click on the badge does not call goBack', async () => { }) test('no history and no breadcrumb rows renders nothing', async () => { - const stub = makeStub({ historyDepth: 0 }) + const stub = makeStub({ historyDepth: 0, trailLabels: ['README.md'] }) const { renderer, captureCharFrame } = await renderHeader(stub) - expect(captureCharFrame()).not.toContain('Back') + expect(captureCharFrame()).not.toContain('README.md') renderer.destroy() }) diff --git a/src/app/components/StickyHeader.crumb.test.tsx b/src/app/components/StickyHeader.crumb.test.tsx index cf231ac..0594b1e 100644 --- a/src/app/components/StickyHeader.crumb.test.tsx +++ b/src/app/components/StickyHeader.crumb.test.tsx @@ -21,6 +21,7 @@ function makeStub(overrides: Partial = {}): AppState { dir: undefined, commands: createNoopCommands(), historyDepth: 0, + trailLabels: [], contentMaxWidth: 80, status: { kind: 'idle' }, ...overrides, diff --git a/src/app/components/StickyHeader.tsx b/src/app/components/StickyHeader.tsx index 11e0388..e1cff98 100644 --- a/src/app/components/StickyHeader.tsx +++ b/src/app/components/StickyHeader.tsx @@ -3,6 +3,8 @@ import { ancestorChain, breadcrumbRows, documentHasH1 } from '../lib/toc-util' import { theme } from '../styles/theme' import { MutedInline } from './blocks/MutedInline' import { onPrimaryClick } from '../lib/mouse' +import { documentTrail } from '../lib/trail' +import type { Crumb } from '../lib/trail' import type { TocEntry } from '../lib/ast' export function StickyHeader({ @@ -14,7 +16,7 @@ export function StickyHeader({ fileLabel?: string onCrumbClick: (id: string) => void }) { - const { currentHeadingId, visibleHeadingIds, contentWidth, historyDepth, backLabel, commands } = + const { currentHeadingId, visibleHeadingIds, contentWidth, historyDepth, trailLabels, commands } = useAppState() const hasH1 = documentHasH1(toc) @@ -22,13 +24,23 @@ export function StickyHeader({ const rows = breadcrumbRows({ chain, visibleHeadingIds, hasH1, fileLabel }) if (rows.length === 0 && historyDepth === 0) return null - const backBadge = + const trailRow = historyDepth > 0 ? ( - - - {'‹'.repeat(historyDepth)} Back - {backLabel ? {` to ${backLabel}`} : ''} - + + {documentTrail({ labels: trailLabels, maxWidth: Math.max(0, contentWidth - 3) }).flatMap( + (crumb, i) => { + const parts = [] + if (i > 0) { + parts.push( + + {' → '} + , + ) + } + parts.push() + return parts + }, + )} ) : null @@ -45,7 +57,7 @@ export function StickyHeader({ paddingX={2} zIndex={10} > - {backBadge} + {trailRow} {rows.map(row => row.variant === 'pill' ? ( ) } + +function TrailCrumb({ crumb, onBack }: { crumb: Crumb; onBack: () => void }) { + if (crumb.kind === 'current') { + return ( + + {crumb.label} + + ) + } + if (crumb.kind === 'back') { + return ( + + {crumb.label} + + ) + } + return {crumb.label} +} diff --git a/src/app/lib/documentNavigation.ts b/src/app/lib/documentNavigation.ts index 5a8cf9d..02b7669 100644 --- a/src/app/lib/documentNavigation.ts +++ b/src/app/lib/documentNavigation.ts @@ -1,4 +1,4 @@ -import { useCallback, useReducer } from 'react' +import { useCallback, useMemo, useReducer } from 'react' import { classifyHref } from './links' import { loadDocument, fileLabel as fileLabelFor } from './loadDocument' @@ -137,7 +137,10 @@ export function useDocumentNavigation(params: { .catch(() => onError('Reload failed: file unreadable')) }, [state.doc.absPath, captureScroll, onError]) - const backLabel = state.history[state.history.length - 1]?.document.fileLabel + const trailLabels = useMemo( + () => [...state.history.map(entry => entry.document.fileLabel), state.doc.fileLabel], + [state.history, state.doc], + ) return { doc: state.doc, @@ -145,7 +148,7 @@ export function useDocumentNavigation(params: { follow, back, reload, - backLabel, + trailLabels, historyDepth: state.history.length, } } diff --git a/src/app/nav.integration.test.tsx b/src/app/nav.integration.test.tsx index d6de1ae..5f037fe 100644 --- a/src/app/nav.integration.test.tsx +++ b/src/app/nav.integration.test.tsx @@ -81,13 +81,13 @@ function pointForLink(params: { return pointForOffset(bearer, inline, mid) } -/** Locates the on-screen cell of the ` ‹ Back ` badge's "Back" substring. */ -function backBadgePoint(frame: string[]): Point { +/** Locates the on-screen cell of the trail's back crumb (the previous doc's label). */ +function backCrumbPoint(frame: string[], label: string): Point { for (let y = 0; y < frame.length; y++) { - const x = frame[y]?.indexOf('Back') ?? -1 + const x = frame[y]?.indexOf(label) ?? -1 if (x >= 0) return { x, y } } - throw new Error('back badge not found in frame') + throw new Error(`back crumb "${label}" not found in frame`) } async function mountA() { @@ -136,8 +136,8 @@ test('click ./b.md navigates to B; Backspace restores A', async () => { expect(root.findDescendantById('document-bravo')).toBeTruthy() expect(root.findDescendantById('document-alpha')).toBeFalsy() expect(captureCharFrame()).toContain('Document Bravo') - // The back affordance appears once history is non-empty. - expect(captureCharFrame()).toContain('Back') + // The trail row appears once history is non-empty. + expect(captureCharFrame()).toContain('nav/a.md') mockInput.pressBackspace() await settle() @@ -165,7 +165,7 @@ test('clicking a navigable link leaves no stray text selection', async () => { renderer.destroy() }) -test('click ./b.md navigates to B; clicking the ‹ Back badge restores A', async () => { +test('click ./b.md navigates to B; clicking the back crumb restores A', async () => { const { renderer, mockMouse, settle, captureCharFrame, root, nodes } = await mountA() const point = pointForLink({ root, nodes, paraIndex: 1, blockId: PARA_B_LINK, needle: 'to B' }) @@ -175,7 +175,7 @@ test('click ./b.md navigates to B; clicking the ‹ Back badge restores A', asyn expect(root.findDescendantById('document-bravo')).toBeTruthy() - const back = backBadgePoint(captureCharFrame().split('\n')) + const back = backCrumbPoint(captureCharFrame().split('\n'), 'nav/a.md') await mockMouse.pressDown(back.x, back.y) await settle() await settle() @@ -204,8 +204,8 @@ test('clicking an external https link does not swap the document', async () => { expect(root.findDescendantById('document-alpha')).toBeTruthy() expect(root.findDescendantById('document-bravo')).toBeFalsy() expect(captureCharFrame()).toContain('Document Alpha') - // No navigation happened, so no back affordance. - expect(captureCharFrame()).not.toContain('Back') + // No navigation happened, so no trail row (its " → " separator is absent). + expect(captureCharFrame()).not.toContain('→') renderer.destroy() }) diff --git a/src/app/state.ts b/src/app/state.ts index b747bde..fc7cf66 100644 --- a/src/app/state.ts +++ b/src/app/state.ts @@ -104,8 +104,8 @@ export type AppState = { dir?: string /** Number of entries on the back stack (drives the back affordance). */ historyDepth: number - /** Label of the document `goBack` would return to (top of the back stack); undefined when empty. */ - backLabel?: string + /** Full navigation label chain, origin first and current doc last; drives the breadcrumb trail. */ + trailLabels: (string | undefined)[] /** Max content column width (configurable; defaults to CONTENT_MAX_WIDTH). */ contentMaxWidth: number From 3e557c6cb992795ad201bd95ad9e4a63a631a281 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Fri, 24 Jul 2026 18:57:47 -0400 Subject: [PATCH 3/4] refactor(viewer): rename back-badge row helper to trail; simplify crumb flatMap --- src/app/components/StickyHeader.tsx | 18 ++++++++---------- src/app/lib/heading-resolution.ts | 10 +++++----- src/app/lib/toc-util.ts | 4 ++-- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/app/components/StickyHeader.tsx b/src/app/components/StickyHeader.tsx index e1cff98..a9bf51f 100644 --- a/src/app/components/StickyHeader.tsx +++ b/src/app/components/StickyHeader.tsx @@ -29,16 +29,14 @@ export function StickyHeader({ {documentTrail({ labels: trailLabels, maxWidth: Math.max(0, contentWidth - 3) }).flatMap( (crumb, i) => { - const parts = [] - if (i > 0) { - parts.push( - - {' → '} - , - ) - } - parts.push() - return parts + const crumbEl = + if (i === 0) return [crumbEl] + return [ + + {' → '} + , + crumbEl, + ] }, )} diff --git a/src/app/lib/heading-resolution.ts b/src/app/lib/heading-resolution.ts index 0440c95..801a567 100644 --- a/src/app/lib/heading-resolution.ts +++ b/src/app/lib/heading-resolution.ts @@ -1,4 +1,4 @@ -import { ancestorChain, backBadgeRowsForDepth, breadcrumbRows, documentHasH1 } from './toc-util' +import { ancestorChain, trailRowsForDepth, breadcrumbRows, documentHasH1 } from './toc-util' import { findHeadingNearTop, findVisibleHeadingIds } from './viewport-geometry' import type { TocEntry } from './ast' import type { BoxGeometry } from './viewport-geometry' @@ -6,8 +6,8 @@ import type { BoxGeometry } from './viewport-geometry' /** * Rows the overlay occludes once `id` is pinned as the current heading: the * ancestor stack (self excluded, since a pinned heading sits visible below the - * fold) plus the back badge when a history exists. This is the offset a jump - * pins below, the resolver's "near top" offset, and the scrollbox tail reserve. + * fold) plus the breadcrumb trail row when a history exists. This is the offset + * a jump pins below, the resolver's "near top" offset, and the scrollbox tail reserve. */ export function foldOffset(params: { toc: TocEntry[] @@ -17,7 +17,7 @@ export function foldOffset(params: { }): number { const { toc, id, fileLabel, historyDepth } = params return ( - backBadgeRowsForDepth(historyDepth) + + trailRowsForDepth(historyDepth) + breadcrumbRows({ chain: ancestorChain(toc, id), visibleHeadingIds: new Set([id]), @@ -30,7 +30,7 @@ export function foldOffset(params: { /** * Rows the breadcrumb shows while `id` sits above the viewport (a search jump * pins the match line to the top, not the heading): the full chain including - * `id`'s own crumb. No back badge. + * `id`'s own crumb. No trail row. */ export function aboveOffset(params: { toc: TocEntry[]; id: string; fileLabel?: string }): number { const { toc, id, fileLabel } = params diff --git a/src/app/lib/toc-util.ts b/src/app/lib/toc-util.ts index 449a81b..8502c90 100644 --- a/src/app/lib/toc-util.ts +++ b/src/app/lib/toc-util.ts @@ -112,8 +112,8 @@ export function breadcrumbRows(params: { return rows } -/** The sticky overlay's back-badge occupies exactly one row whenever a back stack exists. */ -export function backBadgeRowsForDepth(historyDepth: number): number { +/** The sticky overlay's breadcrumb trail occupies exactly one row whenever a back stack exists. */ +export function trailRowsForDepth(historyDepth: number): number { return historyDepth > 0 ? 1 : 0 } From db3576b18e48b2cc1330f6af047b0477f288a244 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Fri, 24 Jul 2026 19:10:01 -0400 Subject: [PATCH 4/4] feat(viewer): navigate to any doc in the trail; chevron separators Clicking any prior crumb jumps straight to that document (popping later history), replacing single-step back-only. Separator glyph is now a chevron. --- src/app/App.tsx | 3 +- src/app/components/StickyHeader.back.test.tsx | 39 ++++++++++++++----- src/app/components/StickyHeader.tsx | 21 +++++----- src/app/lib/commands.test.ts | 2 +- src/app/lib/commands.ts | 6 ++- src/app/lib/dispatch.test.ts | 1 + src/app/lib/documentNavigation.test.ts | 24 ++++++++++++ src/app/lib/documentNavigation.ts | 21 ++++++++++ src/app/lib/trail.test.ts | 24 +++++++----- src/app/lib/trail.ts | 13 ++++--- src/app/nav.integration.test.tsx | 4 +- 11 files changed, 118 insertions(+), 40 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index 6a1e2a8..7d838ef 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -270,7 +270,7 @@ export function App({ renderer.destroy() }, onOpenEditor, - nav: { follow: nav.follow, back: nav.back }, + nav: { follow: nav.follow, back: nav.back, backTo: nav.backTo }, }), [ nodes, @@ -292,6 +292,7 @@ export function App({ nav.historyDepth, nav.follow, nav.back, + nav.backTo, toggleMouse, toggleTocVisible, toggleExpanded, diff --git a/src/app/components/StickyHeader.back.test.tsx b/src/app/components/StickyHeader.back.test.tsx index 4551b5f..fc28841 100644 --- a/src/app/components/StickyHeader.back.test.tsx +++ b/src/app/components/StickyHeader.back.test.tsx @@ -18,8 +18,8 @@ function makeStub(overrides: Partial = {}): AppState { visibleHeadingIds: new Set(), contentWidth: 80, dir: undefined, - // Real Commands surface with an assertable goBack for the trail click test. - commands: { ...createNoopCommands(), goBack: mock() }, + // Real Commands surface with an assertable goToDocument for the trail click tests. + commands: { ...createNoopCommands(), goToDocument: mock() }, historyDepth: 0, trailLabels: [], contentMaxWidth: 80, @@ -47,7 +47,7 @@ async function renderHeader(stub: AppState) { return { renderer, settle, captureCharFrame } } -test('trail renders and clicking the back crumb calls goBack', async () => { +test('clicking the immediate-previous crumb navigates to its document', async () => { const stub = makeStub({ historyDepth: 1, trailLabels: ['README.md', 'docs/guide.md'] }) const { renderer, settle, captureCharFrame } = await renderHeader(stub) const mouse = createMockMouse(renderer) @@ -64,12 +64,13 @@ test('trail renders and clicking the back crumb calls goBack', async () => { await mouse.click(col, row, MouseButtons.LEFT) await settle() - expect(stub.commands.goBack).toHaveBeenCalled() + // README is trail index 0 (the previous doc); the current doc is index 1. + expect(stub.commands.goToDocument).toHaveBeenCalledWith(0) renderer.destroy() }) -test('trail shows the whole chain joined by arrows', async () => { +test('trail shows the whole chain joined by chevrons', async () => { const stub = makeStub({ historyDepth: 3, trailLabels: ['a.md', 'b.md', 'nav/reference.md', 'api.md'], @@ -78,13 +79,13 @@ test('trail shows the whole chain joined by arrows', async () => { const frame = captureCharFrame() expect(frame).toContain('a.md') - expect(frame).toContain('→') + expect(frame).toContain('›') expect(frame).toContain('api.md') renderer.destroy() }) -test('clicking a past crumb does not call goBack', async () => { +test('clicking an older crumb navigates straight to that document', async () => { const stub = makeStub({ historyDepth: 3, trailLabels: ['origin.md', 'mid.md', 'prev.md', 'current.md'], @@ -99,12 +100,13 @@ test('clicking a past crumb does not call goBack', async () => { await mouse.click(col, row, MouseButtons.LEFT) await settle() - expect(stub.commands.goBack).not.toHaveBeenCalled() + // origin.md is the first crumb (trail index 0). + expect(stub.commands.goToDocument).toHaveBeenCalledWith(0) renderer.destroy() }) -test('right-click on the back crumb does not call goBack', async () => { +test('right-click on a crumb does not navigate', async () => { const stub = makeStub({ historyDepth: 1, trailLabels: ['README.md', 'docs/guide.md'] }) const { renderer, settle, captureCharFrame } = await renderHeader(stub) const mouse = createMockMouse(renderer) @@ -116,7 +118,24 @@ test('right-click on the back crumb does not call goBack', async () => { await mouse.click(col, row, MouseButtons.RIGHT) await settle() - expect(stub.commands.goBack).not.toHaveBeenCalled() + expect(stub.commands.goToDocument).not.toHaveBeenCalled() + + renderer.destroy() +}) + +test('clicking the current-doc crumb does not navigate', async () => { + const stub = makeStub({ historyDepth: 1, trailLabels: ['README.md', 'docs/guide.md'] }) + const { renderer, settle, captureCharFrame } = await renderHeader(stub) + const mouse = createMockMouse(renderer) + + const lines = captureCharFrame().split('\n') + const row = lines.findIndex(l => l.includes('docs/guide.md')) + const col = (lines[row] ?? '').indexOf('docs/guide.md') + + await mouse.click(col, row, MouseButtons.LEFT) + await settle() + + expect(stub.commands.goToDocument).not.toHaveBeenCalled() renderer.destroy() }) diff --git a/src/app/components/StickyHeader.tsx b/src/app/components/StickyHeader.tsx index a9bf51f..f4aab2a 100644 --- a/src/app/components/StickyHeader.tsx +++ b/src/app/components/StickyHeader.tsx @@ -29,11 +29,11 @@ export function StickyHeader({ {documentTrail({ labels: trailLabels, maxWidth: Math.max(0, contentWidth - 3) }).flatMap( (crumb, i) => { - const crumbEl = + const crumbEl = if (i === 0) return [crumbEl] return [ - {' → '} + {' › '} , crumbEl, ] @@ -92,7 +92,7 @@ export function StickyHeader({ ) } -function TrailCrumb({ crumb, onBack }: { crumb: Crumb; onBack: () => void }) { +function TrailCrumb({ crumb, onNavigate }: { crumb: Crumb; onNavigate: (index: number) => void }) { if (crumb.kind === 'current') { return ( @@ -100,12 +100,13 @@ function TrailCrumb({ crumb, onBack }: { crumb: Crumb; onBack: () => void }) { ) } - if (crumb.kind === 'back') { - return ( - - {crumb.label} - - ) + if (crumb.kind === 'ellipsis') { + return {crumb.label} } - return {crumb.label} + // Every prior document in the chain is clickable and navigates straight to it. + return ( + onNavigate(crumb.index))}> + {crumb.label} + + ) } diff --git a/src/app/lib/commands.test.ts b/src/app/lib/commands.test.ts index a5c9bbf..67b26f6 100644 --- a/src/app/lib/commands.test.ts +++ b/src/app/lib/commands.test.ts @@ -98,7 +98,7 @@ function makeDeps( set, onQuit: mock(), onOpenEditor: mock(), - nav: { follow: mock(), back: mock() }, + nav: { follow: mock(), back: mock(), backTo: mock() }, } return { deps, set } } diff --git a/src/app/lib/commands.ts b/src/app/lib/commands.ts index bec8e63..0cf49c2 100644 --- a/src/app/lib/commands.ts +++ b/src/app/lib/commands.ts @@ -35,7 +35,7 @@ export type CommandDeps = { } onQuit: () => void onOpenEditor: () => void - nav: { follow: (href: string) => void; back: () => void } + nav: { follow: (href: string) => void; back: () => void; backTo: (index: number) => void } } export type Commands = { @@ -60,6 +60,8 @@ export type Commands = { clearSearch(): void followLink(href: string): void goBack(): void + /** Navigate to a specific document in the history chain by its trail index, discarding docs visited after it. */ + goToDocument(index: number): void openEditor(): void toggleMouse(): void quit(): void @@ -216,6 +218,7 @@ export function createCommands(deps: CommandDeps): Commands { followLink: href => nav.follow(href), goBack: () => nav.back(), + goToDocument: index => nav.backTo(index), openEditor: () => onOpenEditor(), toggleMouse: () => set.toggleMouse(), quit: () => onQuit(), @@ -281,6 +284,7 @@ export function createNoopCommands(): Commands { clearSearch: noop, followLink: noop, goBack: noop, + goToDocument: noop, openEditor: noop, toggleMouse: noop, quit: noop, diff --git a/src/app/lib/dispatch.test.ts b/src/app/lib/dispatch.test.ts index fc71e98..ac3e269 100644 --- a/src/app/lib/dispatch.test.ts +++ b/src/app/lib/dispatch.test.ts @@ -25,6 +25,7 @@ function makeCommands(): Commands { clearSearch: mock(), followLink: mock(), goBack: mock(), + goToDocument: mock(), openEditor: mock(), toggleMouse: mock(), quit: mock(), diff --git a/src/app/lib/documentNavigation.test.ts b/src/app/lib/documentNavigation.test.ts index e5e4c35..a23bcaf 100644 --- a/src/app/lib/documentNavigation.test.ts +++ b/src/app/lib/documentNavigation.test.ts @@ -61,6 +61,30 @@ describe('navReducer', () => { expect(navReducer(state, { type: 'BACK' })).toBe(state) }) + test('BACK_TO an earlier index: restores that doc, discards docs visited after it', () => { + const c = makeDoc('c') + const fromA: HistoryEntry = { document: a, scrollTop: 7, currentHeadingId: 'intro' } + const fromB: HistoryEntry = { document: b, scrollTop: 20, currentHeadingId: 'usage' } + // Chain: a -> b -> c. history = [fromA, fromB], current doc = c. + const atB = navReducer(initial(a), { type: 'FOLLOW_LOADED', doc: b, from: fromA }) + const atC = navReducer(atB, { type: 'FOLLOW_LOADED', doc: c, from: fromB }) + const jumped = navReducer(atC, { type: 'BACK_TO', index: 0 }) + expect(jumped.doc).toBe(a) + expect(jumped.history).toEqual([]) + expect(jumped.intent?.scroll).toEqual({ + kind: 'restore', + scrollTop: 7, + currentHeadingId: 'intro', + }) + expect(jumped.intent?.reset).toBe('full') + }) + + test('BACK_TO an out-of-range index: no-op', () => { + const from: HistoryEntry = { document: a, scrollTop: 0, currentHeadingId: null } + const swapped = navReducer(initial(a), { type: 'FOLLOW_LOADED', doc: b, from }) + expect(navReducer(swapped, { type: 'BACK_TO', index: 5 })).toBe(swapped) + }) + test('RELOAD_LOADED with current heading: unchanged history, searchOnly reset, postSwap anchor', () => { const state: NavState = { doc: a, history: [], intent: null } const next = navReducer(state, { type: 'RELOAD_LOADED', doc: b, anchor: 'intro' }) diff --git a/src/app/lib/documentNavigation.ts b/src/app/lib/documentNavigation.ts index 02b7669..66a36a5 100644 --- a/src/app/lib/documentNavigation.ts +++ b/src/app/lib/documentNavigation.ts @@ -32,6 +32,7 @@ export type NavState = { export type NavAction = | { type: 'FOLLOW_LOADED'; doc: LoadedDocument; from: HistoryEntry; anchor?: string } | { type: 'BACK' } + | { type: 'BACK_TO'; index: number } | { type: 'RELOAD_LOADED'; doc: LoadedDocument; anchor: string | null } | { type: 'IN_DOC_JUMP'; scroll: ScrollIntent } @@ -65,6 +66,23 @@ export function navReducer(state: NavState, action: NavAction): NavState { }, } } + case 'BACK_TO': { + const entry = state.history[action.index] + if (!entry) return state + return { + doc: entry.document, + history: state.history.slice(0, action.index), + intent: { + scroll: { + kind: 'restore', + scrollTop: entry.scrollTop, + currentHeadingId: entry.currentHeadingId, + }, + reset: 'full', + seq, + }, + } + } case 'RELOAD_LOADED': { const scroll: ScrollIntent = action.anchor ? { kind: 'anchor', headingId: action.anchor, postSwap: true } @@ -128,6 +146,8 @@ export function useDocumentNavigation(params: { const back = useCallback(() => doDispatch({ type: 'BACK' }), []) + const backTo = useCallback((index: number) => doDispatch({ type: 'BACK_TO', index }), []) + const reload = useCallback(() => { const path = state.doc.absPath if (!path) return @@ -147,6 +167,7 @@ export function useDocumentNavigation(params: { intent: state.intent, follow, back, + backTo, reload, trailLabels, historyDepth: state.history.length, diff --git a/src/app/lib/trail.test.ts b/src/app/lib/trail.test.ts index 3b17659..23f77ea 100644 --- a/src/app/lib/trail.test.ts +++ b/src/app/lib/trail.test.ts @@ -3,17 +3,23 @@ import { documentTrail } from './trail' test('single doc yields one current crumb, no separators implied', () => { const crumbs = documentTrail({ labels: ['README.md'], maxWidth: 80 }) - expect(crumbs).toEqual([{ label: 'README.md', kind: 'current' }]) + expect(crumbs).toEqual([{ label: 'README.md', kind: 'current', index: 0 }]) }) test('two docs yield back then current', () => { const crumbs = documentTrail({ labels: ['README.md', 'docs/guide.md'], maxWidth: 80 }) expect(crumbs).toEqual([ - { label: 'README.md', kind: 'back' }, - { label: 'docs/guide.md', kind: 'current' }, + { label: 'README.md', kind: 'back', index: 0 }, + { label: 'docs/guide.md', kind: 'current', index: 1 }, ]) }) +test('each crumb carries its source index; the ellipsis carries -1', () => { + const labels = ['origin.md', 'one.md', 'two.md', 'three.md', 'docs/guide.md', 'api.md'] + const crumbs = documentTrail({ labels, maxWidth: 20 }) + expect(crumbs.map(c => c.index)).toEqual([0, -1, 4, 5]) +}) + test('deep chain within width keeps every crumb and tags them', () => { const crumbs = documentTrail({ labels: ['a.md', 'b.md', 'c.md', 'd.md'], @@ -26,10 +32,10 @@ test('overflow collapses the middle, keeping first + last two', () => { const labels = ['origin.md', 'one.md', 'two.md', 'three.md', 'docs/guide.md', 'api.md'] const crumbs = documentTrail({ labels, maxWidth: 20 }) expect(crumbs).toEqual([ - { label: 'origin.md', kind: 'past' }, - { label: '…', kind: 'ellipsis' }, - { label: 'docs/guide.md', kind: 'back' }, - { label: 'api.md', kind: 'current' }, + { label: 'origin.md', kind: 'past', index: 0 }, + { label: '…', kind: 'ellipsis', index: -1 }, + { label: 'docs/guide.md', kind: 'back', index: 4 }, + { label: 'api.md', kind: 'current', index: 5 }, ]) }) @@ -45,8 +51,8 @@ test('overflow never truncates the back or current crumb', () => { test('undefined labels fall back to a placeholder', () => { const crumbs = documentTrail({ labels: [undefined, 'api.md'], maxWidth: 80 }) expect(crumbs).toEqual([ - { label: '', kind: 'back' }, - { label: 'api.md', kind: 'current' }, + { label: '', kind: 'back', index: 0 }, + { label: 'api.md', kind: 'current', index: 1 }, ]) }) diff --git a/src/app/lib/trail.ts b/src/app/lib/trail.ts index 44ab1cb..181a806 100644 --- a/src/app/lib/trail.ts +++ b/src/app/lib/trail.ts @@ -1,15 +1,16 @@ export type CrumbKind = 'current' | 'back' | 'past' | 'ellipsis' -export type Crumb = { label: string; kind: CrumbKind } +/** `index` is the crumb's position in the source label chain (its navigation target); `-1` for the ellipsis. */ +export type Crumb = { label: string; kind: CrumbKind; index: number } -const SEP = ' → ' +const SEP = ' › ' const ELLIPSIS = '…' const UNTITLED = '' /** * Maps an ordered list of document labels (origin first, current doc last) into - * tagged breadcrumb crumbs. When the chain joined by " → " exceeds `maxWidth`, + * tagged breadcrumb crumbs. When the chain joined by " › " exceeds `maxWidth`, * the middle collapses to a single ellipsis crumb, always keeping the first - * crumb and the last two (the clickable `back` crumb and the `current` doc). + * crumb and the last two (the immediate-previous `back` crumb and the `current` doc). */ export function documentTrail({ labels, @@ -21,7 +22,7 @@ export function documentTrail({ const named = labels.map(label => label ?? UNTITLED) if (named.length === 0) return [] - const full = named.map((label, i): Crumb => ({ label, kind: tagAt(i, named.length) })) + const full = named.map((label, i): Crumb => ({ label, kind: tagAt(i, named.length), index: i })) if (named.length <= 3 || trailWidth(full) <= maxWidth) return full const first = full[0] @@ -29,7 +30,7 @@ export function documentTrail({ const current = full[full.length - 1] if (!first || !back || !current) return full - return [first, { label: ELLIPSIS, kind: 'ellipsis' }, back, current] + return [first, { label: ELLIPSIS, kind: 'ellipsis', index: -1 }, back, current] } function tagAt(i: number, n: number): CrumbKind { diff --git a/src/app/nav.integration.test.tsx b/src/app/nav.integration.test.tsx index 5f037fe..628ef9e 100644 --- a/src/app/nav.integration.test.tsx +++ b/src/app/nav.integration.test.tsx @@ -204,8 +204,8 @@ test('clicking an external https link does not swap the document', async () => { expect(root.findDescendantById('document-alpha')).toBeTruthy() expect(root.findDescendantById('document-bravo')).toBeFalsy() expect(captureCharFrame()).toContain('Document Alpha') - // No navigation happened, so no trail row (its " → " separator is absent). - expect(captureCharFrame()).not.toContain('→') + // No navigation happened, so no trail row (its " › " separator is absent). + expect(captureCharFrame()).not.toContain('›') renderer.destroy() })