diff --git a/src/report/client.ts b/src/report/client.ts index 1aaf058..9828536 100644 --- a/src/report/client.ts +++ b/src/report/client.ts @@ -36,10 +36,12 @@ export function mountReport( const initialRender = new Promise((resolve) => (finishInitialRender = resolve)) wireSectionFolds() wireFragments(initialRender) - const mountedDiffs = mountDiffs(data, layout, createFileDiff) + const overflow = initialOverflow() + const mountedDiffs = mountDiffs(data, layout, overflow, createFileDiff) void mountedDiffs.initialRender.then(finishInitialRender) const { mounted } = mountedDiffs wireLayout(mounted) + wireWrap(mounted) prepareForPrint() } @@ -57,6 +59,12 @@ function initialLayout(): 'split' | 'unified' { return checked?.value === 'unified' ? 'unified' : 'split' } +function initialOverflow(): 'scroll' | 'wrap' { + const form = document.querySelector('[data-wrap-form]') + const checked = form?.querySelector('input[name="wrap"]') + return checked?.checked ? 'wrap' : 'scroll' +} + function isNarrowViewport(): boolean { return typeof matchMedia === 'function' && matchMedia('(max-width: 900px)').matches } @@ -81,10 +89,12 @@ function showStepError(mount: ReportDiffMount, error: unknown) { function baseOptions( diffStyle: 'split' | 'unified', + overflow: 'scroll' | 'wrap', onPostRender?: FileDiffOptions['onPostRender'], ) { return { diffStyle, + overflow, themeType: 'light', disableFileHeader: true, onPostRender, @@ -94,6 +104,7 @@ function baseOptions( function mountDiffs( data: ReportData, layout: 'split' | 'unified', + overflow: 'scroll' | 'wrap', createFileDiff: FileDiffFactory, ): { mounted: MountedDiff[]; initialRender: Promise } { const mounted: MountedDiff[] = [] @@ -130,7 +141,7 @@ function mountDiffs( ) try { const instance = createFileDiff( - baseOptions(layout, (_node, _instance, phase) => { + baseOptions(layout, overflow, (_node, _instance, phase) => { if (phase === 'mount') finishRender() }), ) @@ -323,6 +334,18 @@ function wireFragments(initialRender: Promise) { }) } +function wireWrap(mounted: MountedDiff[]) { + const form = document.querySelector('[data-wrap-form]') + form?.addEventListener('change', (event) => { + if (!(event.target as Element).matches('input[name="wrap"]')) return + const overflow = (event.target as HTMLInputElement).checked ? 'wrap' : 'scroll' + for (const { fileDiff, instance } of mounted) { + instance.setOptions({ ...instance.options, overflow }) + instance.render({ fileDiff, forceRender: true }) + } + }) +} + function wireLayout(mounted: MountedDiff[]) { const form = document.querySelector('[data-layout-form]') form?.addEventListener('change', (event) => { diff --git a/src/report/render.ts b/src/report/render.ts index 0b4ac10..2131936 100644 --- a/src/report/render.ts +++ b/src/report/render.ts @@ -11,6 +11,7 @@ export type ReportLayout = 'split' | 'unified' export interface ReportOptions { title?: string layout?: ReportLayout + wrap?: boolean } export interface HostedAssets { @@ -66,6 +67,7 @@ function renderReportBody( ): ReportBody { const title = options.title ?? document.title const layout = options.layout ?? 'split' + const wrap = options.wrap ?? false const targets = reportTargets(document) const sections = document.sections.map((section, index) => renderSection(section, index, targets[index]!), @@ -76,7 +78,10 @@ function renderReportBody( ` - const readingControls = `
${layoutForm}
` + const wrapForm = `
+ +
` + const readingControls = `
${layoutForm}${wrapForm}
` const reviewMap = renderReviewMap( document.sections.map((section, index) => ({ title: section.title, @@ -391,7 +396,7 @@ body { gap: 8px; margin: 0 10px 20px; } -.layout-form { +.layout-form, .wrap-form { display: flex; margin: 0; border: 1px solid #bdcbbf; @@ -399,9 +404,9 @@ body { overflow: hidden; background: #f3f7f3; } -.layout-form label { flex: 1; padding: 5px 10px; color: #607066; font-size: 13px; text-align: center; cursor: pointer; } -.layout-form input { display: none; } -.layout-form label:has(input:checked) { color: #ffffff; background: var(--accent); font-weight: 600; } +.layout-form label, .wrap-form label { flex: 1; padding: 5px 10px; color: #607066; font-size: 13px; text-align: center; cursor: pointer; } +.layout-form input, .wrap-form input { display: none; } +.layout-form label:has(input:checked), .wrap-form label:has(input:checked) { color: #ffffff; background: var(--accent); font-weight: 600; } .fold-all { padding: 6px 10px; border: 1px solid #bdcbbf; @@ -613,6 +618,7 @@ main { max-width: none; min-width: 0; margin: 0; padding: 22px 28px 72px; } .review-map-label, .review-map-list, .review-map-counts { display: none; } .review-controls { display: flex; gap: 6px; justify-content: flex-end; margin: 0; } .layout-form { flex: 1 1 auto; max-width: 220px; margin: 0 0 0 auto; } + .wrap-form { flex: none; } .fold-all { flex: none; padding: 5px 9px; font-size: 12px; } /* The aligned fragment target must clear the sticky strip, so push its scroll-margin past the strip plus breathing room. */ @@ -622,12 +628,12 @@ main { max-width: none; min-width: 0; margin: 0; padding: 22px 28px 72px; } @media (max-width: 520px) { .report-cover { padding: 18px 16px 8px; } .report-cover h1 { font-size: 21px; } - .layout-form label { padding: 4px 7px; font-size: 11px; } + .layout-form label, .wrap-form label { padding: 4px 7px; font-size: 11px; } .fold-all { padding: 4px 7px; font-size: 11px; } .section-fold > summary { font-size: 17px; } } @media print { - .layout-form, .permalink { display: none; } + .layout-form, .wrap-form, .permalink { display: none; } .review-map { display: none; } .review-workspace { display: block; } .report-cover { box-shadow: none; break-inside: avoid; } diff --git a/test/report-dom.test.ts b/test/report-dom.test.ts index ba9dacc..348db41 100644 --- a/test/report-dom.test.ts +++ b/test/report-dom.test.ts @@ -180,6 +180,20 @@ function submitLayout(dom: Window, value: 'split' | 'unified') { ) } +function overflowMode(dom: Window, index: number) { + const shadow = fileDiffShadow(dom, index) + return shadow?.querySelector('pre')?.getAttribute('data-overflow') ?? null +} + +function toggleWrap(dom: Window, on: boolean) { + const doc = dom.document as unknown as Document + const input = doc.querySelector('input[name="wrap"]') + input!.checked = on + input!.dispatchEvent( + new dom.window.Event('change', { bubbles: true, cancelable: true }) as unknown as Event, + ) +} + function diffContainers(dom: Window) { const doc = dom.document as unknown as Document return [...doc.querySelectorAll('.file-diff diffs-container')] @@ -822,6 +836,32 @@ describe('report browser client', () => { expect(button.getAttribute('aria-expanded')).toBe('true') }) + test( + 'wrap toggle flips overflow on the same instances', + async () => { + const html = renderReport( + document([section(simplePatch('one', 'one!'), 'Wrap section')]), + clientBundle, + ) + const dom = loadReport(html) + + runReportClient() + + await waitFor(() => mountedCount(dom) === 1, 90000) + await waitForQuiescent(dom) + expect(overflowMode(dom, 0)).toBe('scroll') + + const before = diffContainers(dom) + toggleWrap(dom, true) + await waitFor(() => overflowMode(dom, 0) === 'wrap', 30000) + expectSameContainers(before, diffContainers(dom)) + + toggleWrap(dom, false) + await waitFor(() => overflowMode(dom, 0) === 'scroll', 30000) + }, + 120000, + ) + test( 'narrow viewport defaults split reports to unified but preserves explicit switching', async () => { diff --git a/test/report.test.ts b/test/report.test.ts index 5b74f4a..45d96a7 100644 --- a/test/report.test.ts +++ b/test/report.test.ts @@ -338,6 +338,22 @@ describe('renderReport shell', () => { expect(html).toContain('value="unified" checked') }) + test('wrap checkbox rides alongside the layout radio and defaults to off', () => { + const html = renderReport(document([section(simplePatch(), 'Wrap')]), stubClient) + + expect(html).toContain('data-wrap-form') + expect(html).toContain('name="wrap"') + expect(html).not.toContain('name="wrap" checked') + }) + + test('wrapped lines are preselected when requested', () => { + const html = renderReport(document([section(simplePatch(), 'Wrap')]), stubClient, { + wrap: true, + }) + + expect(html).toContain('name="wrap" checked') + }) + test('each open section has local fold controls and files start collapsed', () => { const html = renderReport(document([section(simplePatch(), 'Plain')]), stubClient)