Skip to content

Commit 6164ea0

Browse files
committed
fix(diff): keep comparison headings aligned while scrolling
1 parent c126c24 commit 6164ea0

1 file changed

Lines changed: 125 additions & 62 deletions

File tree

‎apps/sim/components/diff/diff-view.tsx‎

Lines changed: 125 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ interface Segment {
2929
type VisibleItem = { line: DiffLine; key: number } | { collapsed: number; key: number }
3030
type PairedRow = { old?: DiffLine; new?: DiffLine; key: number }
3131
type 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

3337
interface DiffViewProps {
3438
hunks: DiffHunk[]
@@ -52,27 +56,34 @@ interface HighlightedTextProps {
5256
interface CollapsedRunProps {
5357
count: number
5458
onExpand: () => void
59+
row?: number
5560
}
5661

5762
interface DiffCellProps extends Omit<LineTextProps, 'prose'> {
58-
side: 'old' | 'new'
5963
wrapLines: boolean
6064
}
6165
interface 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+
}
7082
interface 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) {
381449
export 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

Comments
 (0)