Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -292,6 +292,7 @@ export function App({
nav.historyDepth,
nav.follow,
nav.back,
nav.backTo,
toggleMouse,
toggleTocVisible,
toggleExpanded,
Expand All @@ -312,7 +313,7 @@ export function App({
contentMaxWidth,
dir: nav.doc.dir,
historyDepth: nav.historyDepth,
backLabel,
trailLabels,
status,
commands,
}),
Expand All @@ -327,7 +328,7 @@ export function App({
contentMaxWidth,
nav.doc.dir,
nav.historyDepth,
backLabel,
trailLabels,
status,
commands,
],
Expand Down
2 changes: 1 addition & 1 deletion src/app/RenderView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function RenderView({
contentMaxWidth,
dir: undefined,
historyDepth: 0,
backLabel: undefined,
trailLabels: [],
status: { kind: 'idle' },
commands,
}),
Expand Down
84 changes: 64 additions & 20 deletions src/app/components/StickyHeader.back.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ function makeStub(overrides: Partial<AppState> = {}): AppState {
visibleHeadingIds: new Set<string>(),
contentWidth: 80,
dir: undefined,
// Real Commands surface with an assertable goBack for the back-badge 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,
status: { kind: 'idle' },
...overrides,
Expand All @@ -46,61 +47,104 @@ 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('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)

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()

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('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 chevrons', 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 an older crumb navigates straight to that document', 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()

// origin.md is the first crumb (trail index 0).
expect(stub.commands.goToDocument).toHaveBeenCalledWith(0)

renderer.destroy()
})

test('right-click on the badge does not call goBack', async () => {
const stub = makeStub({ historyDepth: 1 })
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)

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()

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()
})

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()
})
1 change: 1 addition & 0 deletions src/app/components/StickyHeader.crumb.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function makeStub(overrides: Partial<AppState> = {}): AppState {
dir: undefined,
commands: createNoopCommands(),
historyDepth: 0,
trailLabels: [],
contentMaxWidth: 80,
status: { kind: 'idle' },
...overrides,
Expand Down
45 changes: 37 additions & 8 deletions src/app/components/StickyHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -14,21 +16,29 @@ 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)
const chain = ancestorChain(toc, currentHeadingId)
const rows = breadcrumbRows({ chain, visibleHeadingIds, hasH1, fileLabel })
if (rows.length === 0 && historyDepth === 0) return null

const backBadge =
const trailRow =
historyDepth > 0 ? (
<box key="__back" height={1} overflow="hidden" onMouseDown={onPrimaryClick(commands.goBack)}>
<text>
<strong>{'‹'.repeat(historyDepth)} Back</strong>
{backLabel ? <span fg={theme.foregroundMuted}>{` to ${backLabel}`}</span> : ''}
</text>
<box key="__trail" height={1} flexDirection="row" overflow="hidden">
{documentTrail({ labels: trailLabels, maxWidth: Math.max(0, contentWidth - 3) }).flatMap(
(crumb, i) => {
const crumbEl = <TrailCrumb key={i} crumb={crumb} onNavigate={commands.goToDocument} />
if (i === 0) return [crumbEl]
return [
<text key={`sep-${i}`} fg={theme.foregroundMuted}>
{' › '}
</text>,
crumbEl,
]
},
)}
</box>
) : null

Expand All @@ -45,7 +55,7 @@ export function StickyHeader({
paddingX={2}
zIndex={10}
>
{backBadge}
{trailRow}
{rows.map(row =>
row.variant === 'pill' ? (
<box
Expand Down Expand Up @@ -81,3 +91,22 @@ export function StickyHeader({
</box>
)
}

function TrailCrumb({ crumb, onNavigate }: { crumb: Crumb; onNavigate: (index: number) => void }) {
if (crumb.kind === 'current') {
return (
<text fg={theme.heading}>
<strong>{crumb.label}</strong>
</text>
)
}
if (crumb.kind === 'ellipsis') {
return <text fg={theme.foregroundMuted}>{crumb.label}</text>
}
// Every prior document in the chain is clickable and navigates straight to it.
return (
<box overflow="hidden" onMouseDown={onPrimaryClick(() => onNavigate(crumb.index))}>
<text fg={theme.foregroundMuted}>{crumb.label}</text>
</box>
)
}
2 changes: 1 addition & 1 deletion src/app/lib/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down
6 changes: 5 additions & 1 deletion src/app/lib/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -281,6 +284,7 @@ export function createNoopCommands(): Commands {
clearSearch: noop,
followLink: noop,
goBack: noop,
goToDocument: noop,
openEditor: noop,
toggleMouse: noop,
quit: noop,
Expand Down
1 change: 1 addition & 0 deletions src/app/lib/dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function makeCommands(): Commands {
clearSearch: mock(),
followLink: mock(),
goBack: mock(),
goToDocument: mock(),
openEditor: mock(),
toggleMouse: mock(),
quit: mock(),
Expand Down
24 changes: 24 additions & 0 deletions src/app/lib/documentNavigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down
Loading