@@ -29,6 +29,10 @@ interface Segment {
2929type VisibleItem = { line : DiffLine ; key : number } | { collapsed : number ; key : number }
3030type PairedRow = { old ?: DiffLine ; new ?: DiffLine ; key : number }
3131type PairedItem = PairedRow | { collapsed : number ; key : number }
32+ type ProseItem =
33+ | { kind : 'heading' ; hunk : DiffHunk ; key : string }
34+ | { kind : 'collapsed' ; count : number ; key : string }
35+ | { kind : 'row' ; item : PairedRow ; segments : Map < DiffLine , Segment [ ] > ; key : string }
3236
3337interface DiffViewProps {
3438 hunks : DiffHunk [ ]
@@ -52,27 +56,34 @@ interface HighlightedTextProps {
5256interface CollapsedRunProps {
5357 count : number
5458 onExpand : ( ) => void
59+ row ?: number
5560}
5661
5762interface DiffCellProps extends Omit < LineTextProps , 'prose' > {
58- side : 'old' | 'new'
5963 wrapLines : boolean
6064}
6165interface UnifiedRowProps extends Omit < LineTextProps , 'prose' > {
6266 numbered : boolean
6367 wrapLines : boolean
6468}
65- interface ProseRowProps {
66- item : PairedRow
67- segments : Map < DiffLine , Segment [ ] >
69+ interface ProseColumnProps {
70+ items : ProseItem [ ]
71+ side : 'old' | 'new'
6872 wrapLines : boolean
6973}
74+ interface ProseRowsProps {
75+ hunks : DiffHunk [ ]
76+ wrapLines : boolean
77+ }
78+ interface HunkHeadingProps {
79+ hunk : DiffHunk
80+ row ?: number
81+ }
7082interface HunkRowsProps {
7183 hunk : DiffHunk
7284 index : number
7385 numbered : boolean
7486 wrapLines : boolean
75- prose : boolean
7687}
7788
7889/** Pairs adjacent removed and added runs for bounded word-level highlighting. */
@@ -174,9 +185,12 @@ function LineText({ line, segments, prose }: LineTextProps) {
174185 )
175186}
176187
177- function CollapsedRun ( { count, onExpand } : CollapsedRunProps ) {
188+ function CollapsedRun ( { count, onExpand, row } : CollapsedRunProps ) {
178189 return (
179- < div className = 'sticky left-0 col-span-full border-[var(--border)] border-y bg-[var(--surface-3)] px-3 py-1' >
190+ < div
191+ className = 'sticky left-0 col-span-full border-[var(--border)] border-y bg-[var(--surface-3)] px-3 py-1'
192+ style = { row ? { gridRow : row } : undefined }
193+ >
180194 < Chip onClick = { onExpand } leftIcon = { ChevronsUpDown } >
181195 Show { count } unchanged { count === 1 ? 'line' : 'lines' }
182196 </ Chip >
@@ -261,15 +275,9 @@ function marker(line: DiffLine | undefined) {
261275 return line ?. type === 'add' ? '+' : line ?. type === 'del' ? '−' : ' '
262276}
263277
264- function DiffCell ( { line, segments, side , wrapLines } : DiffCellProps ) {
278+ function DiffCell ( { line, segments, wrapLines } : DiffCellProps ) {
265279 return (
266- < div
267- className = { cn (
268- 'grid min-w-0 grid-cols-[24px_minmax(0,1fr)] px-3 py-2' ,
269- side === 'new' && 'border-[var(--border)] border-l' ,
270- rowClass ( line )
271- ) }
272- >
280+ < div className = { cn ( 'grid min-w-0 grid-cols-[24px_minmax(0,1fr)] px-3 py-2' , rowClass ( line ) ) } >
273281 < span className = { cn ( MARKER , markerClass ( line ) ) } aria-hidden >
274282 { marker ( line ) }
275283 </ span >
@@ -313,64 +321,124 @@ function UnifiedRow({ line, segments, numbered, wrapLines }: UnifiedRowProps) {
313321 )
314322}
315323
316- function ProseRow ( { item, segments, wrapLines } : ProseRowProps ) {
324+ function ProseColumn ( { items, side, wrapLines } : ProseColumnProps ) {
325+ const [ scrollElement , setScrollElement ] = useState < HTMLDivElement | null > ( null )
326+ const scrollEdges = useScrollEdges ( scrollElement , { axis : 'x' } )
317327 return (
318- < div className = 'col-span-full grid grid-cols-subgrid' >
319- { ( [ 'old' , 'new' ] as const ) . map ( ( side ) => {
320- const line = item [ side ]
321- return line ? (
322- < DiffCell
323- key = { side }
324- line = { line }
325- segments = { segments . get ( line ) }
326- side = { side }
327- wrapLines = { wrapLines }
328- />
329- ) : (
330- < div
331- key = { side }
332- aria-hidden
333- className = { cn (
334- 'bg-[var(--surface-3)]' ,
335- side === 'new' && 'border-[var(--border)] border-l'
336- ) }
328+ < div
329+ ref = { setScrollElement }
330+ role = 'region'
331+ aria-label = { side === 'old' ? 'Before document' : 'After document' }
332+ tabIndex = { wrapLines ? undefined : 0 }
333+ className = { cn (
334+ scrollFadeXClass ,
335+ 'row-span-full grid min-w-0 grid-rows-subgrid overflow-x-auto' ,
336+ side === 'old' ? 'col-start-1' : 'col-start-2 border-[var(--border)] border-l'
337+ ) }
338+ { ...scrollFadeAttributes ( scrollEdges ) }
339+ >
340+ < div
341+ className = { cn (
342+ 'row-span-full grid grid-rows-subgrid' ,
343+ wrapLines ? 'w-full min-w-0' : 'w-max min-w-full'
344+ ) }
345+ >
346+ { items . map ( ( entry ) => {
347+ const line = entry . kind === 'row' ? entry . item [ side ] : undefined
348+ return line && entry . kind === 'row' ? (
349+ < DiffCell
350+ key = { entry . key }
351+ line = { line }
352+ segments = { entry . segments . get ( line ) }
353+ wrapLines = { wrapLines }
354+ />
355+ ) : (
356+ < div
357+ key = { entry . key }
358+ aria-hidden
359+ className = { cn ( entry . kind === 'row' && 'bg-[var(--surface-3)]' ) }
360+ />
361+ )
362+ } ) }
363+ </ div >
364+ </ div >
365+ )
366+ }
367+
368+ function HunkHeading ( { hunk, row } : HunkHeadingProps ) {
369+ return (
370+ < div
371+ className = 'sticky left-0 col-span-full border-[var(--border)] border-y bg-[var(--surface-3)] px-4 py-2 font-season text-[var(--text-muted)] text-caption'
372+ style = { row ? { gridRow : row } : undefined }
373+ >
374+ { hunk . file && < span className = 'text-[var(--text-body)]' > { hunk . file } </ span > }
375+ { hunk . heading || ( hunk . file ? '' : '⋯' ) }
376+ </ div >
377+ )
378+ }
379+
380+ function ProseRows ( { hunks, wrapLines } : ProseRowsProps ) {
381+ const [ expanded , setExpanded ] = useState < ReadonlySet < string > > ( ( ) => new Set ( ) )
382+ const items : ProseItem [ ] = [ ]
383+ for ( const [ index , hunk ] of hunks . entries ( ) ) {
384+ if ( hunk . file || hunk . heading || index > 0 )
385+ items . push ( { kind : 'heading' , hunk, key : `${ index } :heading` } )
386+ const segments = wordSegments ( hunk . lines )
387+ const visible = visibleItems ( hunk . lines , ( start ) => expanded . has ( `${ index } :c${ start } ` ) )
388+ for ( const item of pairedItems ( visible ) ) {
389+ items . push (
390+ 'collapsed' in item
391+ ? { kind : 'collapsed' , count : item . collapsed , key : `${ index } :c${ item . key } ` }
392+ : { kind : 'row' , item, segments, key : `${ index } :${ item . key } ` }
393+ )
394+ }
395+ }
396+ if ( items . length === 0 ) return null
397+ return (
398+ < div
399+ className = 'grid w-full grid-cols-2 py-1 font-season text-[var(--text-body)] text-small leading-relaxed'
400+ style = { { gridTemplateRows : `repeat(${ items . length } , auto)` } }
401+ >
402+ < ProseColumn items = { items } side = 'old' wrapLines = { wrapLines } />
403+ < ProseColumn items = { items } side = 'new' wrapLines = { wrapLines } />
404+ { items . map ( ( entry , index ) =>
405+ entry . kind === 'heading' ? (
406+ < HunkHeading key = { entry . key } hunk = { entry . hunk } row = { index + 1 } />
407+ ) : entry . kind === 'collapsed' ? (
408+ < CollapsedRun
409+ key = { entry . key }
410+ count = { entry . count }
411+ row = { index + 1 }
412+ onExpand = { ( ) => setExpanded ( ( current ) => new Set ( current ) . add ( entry . key ) ) }
337413 />
338- )
339- } ) }
414+ ) : null
415+ ) }
340416 </ div >
341417 )
342418}
343419
344- function HunkRows ( { hunk, index, numbered, wrapLines, prose } : HunkRowsProps ) {
420+ function HunkRows ( { hunk, index, numbered, wrapLines } : HunkRowsProps ) {
345421 const [ expanded , setExpanded ] = useState < ReadonlySet < number > > ( ( ) => new Set ( ) )
346422 const segments = wordSegments ( hunk . lines )
347- const visible = visibleItems ( hunk . lines , ( start ) => expanded . has ( start ) )
348- const items = prose ? pairedItems ( visible ) : visible
423+ const items = visibleItems ( hunk . lines , ( start ) => expanded . has ( start ) )
349424 return (
350425 < >
351- { ( hunk . file || hunk . heading || index > 0 ) && (
352- < div className = 'sticky left-0 col-span-full border-[var(--border)] border-y bg-[var(--surface-3)] px-4 py-2 font-season text-[var(--text-muted)] text-caption' >
353- { hunk . file && < span className = 'text-[var(--text-body)]' > { hunk . file } </ span > }
354- { hunk . heading || ( hunk . file ? '' : '⋯' ) }
355- </ div >
356- ) }
426+ { ( hunk . file || hunk . heading || index > 0 ) && < HunkHeading hunk = { hunk } /> }
357427 { items . map ( ( item ) =>
358428 'collapsed' in item ? (
359429 < CollapsedRun
360430 key = { `c${ item . key } ` }
361431 count = { item . collapsed }
362432 onExpand = { ( ) => setExpanded ( ( current ) => new Set ( current ) . add ( item . key ) ) }
363433 />
364- ) : 'line' in item ? (
434+ ) : (
365435 < UnifiedRow
366436 key = { item . key }
367437 line = { item . line }
368438 segments = { segments . get ( item . line ) }
369439 numbered = { numbered }
370440 wrapLines = { wrapLines }
371441 />
372- ) : (
373- < ProseRow key = { item . key } item = { item } segments = { segments } wrapLines = { wrapLines } />
374442 )
375443 ) }
376444 </ >
@@ -381,16 +449,13 @@ function HunkRows({ hunk, index, numbered, wrapLines, prose }: HunkRowsProps) {
381449export function DiffView ( { hunks, wrapLines = true , prose = false } : DiffViewProps ) {
382450 const [ scrollElement , setScrollElement ] = useState < HTMLDivElement | null > ( null )
383451 const scrollEdges = useScrollEdges ( scrollElement , { axis : 'x' } )
384- const numbered =
385- ! prose &&
386- hunks . some ( ( hunk ) =>
387- hunk . lines . some ( ( line ) => line . oldLine !== undefined || line . newLine !== undefined )
388- )
389- const columns = prose
390- ? 'grid-cols-2'
391- : numbered
392- ? 'grid-cols-[minmax(3.5ch,auto)_minmax(3.5ch,auto)_24px_minmax(0,1fr)]'
393- : 'grid-cols-[24px_minmax(0,1fr)]'
452+ if ( prose ) return < ProseRows hunks = { hunks } wrapLines = { wrapLines } />
453+ const numbered = hunks . some ( ( hunk ) =>
454+ hunk . lines . some ( ( line ) => line . oldLine !== undefined || line . newLine !== undefined )
455+ )
456+ const columns = numbered
457+ ? 'grid-cols-[minmax(3.5ch,auto)_minmax(3.5ch,auto)_24px_minmax(0,1fr)]'
458+ : 'grid-cols-[24px_minmax(0,1fr)]'
394459 return (
395460 < div
396461 ref = { setScrollElement }
@@ -401,8 +466,7 @@ export function DiffView({ hunks, wrapLines = true, prose = false }: DiffViewPro
401466 className = { cn (
402467 'grid py-1 font-mono text-[var(--text-body)] text-small leading-[22px]' ,
403468 columns ,
404- wrapLines ? 'w-full' : 'w-max min-w-full' ,
405- prose && 'font-season leading-relaxed'
469+ wrapLines ? 'w-full' : 'w-max min-w-full'
406470 ) }
407471 >
408472 { hunks . map ( ( hunk , index ) => (
@@ -412,7 +476,6 @@ export function DiffView({ hunks, wrapLines = true, prose = false }: DiffViewPro
412476 index = { index }
413477 numbered = { numbered }
414478 wrapLines = { wrapLines }
415- prose = { prose }
416479 />
417480 ) ) }
418481 </ div >
0 commit comments