Skip to content

Commit 1b644e8

Browse files
feat(codecov): Introduce Virtual Diff Renderer (#89853)
This PR adds in a virtual diff renderer. This render comes packed with a couple of features: - Tokenizes & highlights diff content - "Scroll to line" after a user has selected a line and navigates to that page again - Dynamically enabled scrollbar - Transparent `textarea` for native search - Coverage highlighting <img width="1192" alt="Screenshot 2025-04-17 at 09 18 21" src="https://github.com/user-attachments/assets/00428fe1-71aa-456c-aae9-2a02170b0e28" />
1 parent 20a37f9 commit 1b644e8

File tree

1 file changed

+382
-0
lines changed

1 file changed

+382
-0
lines changed
Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
1+
import {useEffect, useRef} from 'react';
2+
import {css} from '@emotion/react';
3+
import styled from '@emotion/styled';
4+
import * as Sentry from '@sentry/react';
5+
import {useWindowVirtualizer, type Virtualizer} from '@tanstack/react-virtual';
6+
7+
import {ColorBar} from 'sentry/components/codecov/virtualRenderers/colorBar';
8+
import {
9+
type CoverageValue,
10+
LINE_HEIGHT,
11+
} from 'sentry/components/codecov/virtualRenderers/constants';
12+
import {
13+
LineNumber,
14+
LineNumberColumn,
15+
} from 'sentry/components/codecov/virtualRenderers/lineNumber';
16+
import {ScrollBar} from 'sentry/components/codecov/virtualRenderers/scrollBar';
17+
import {useCodeHighlighting} from 'sentry/components/codecov/virtualRenderers/useCodeHighlighter';
18+
import {useDisablePointerEvents} from 'sentry/components/codecov/virtualRenderers/useDisablePointerEvents';
19+
import {useIsOverflowing} from 'sentry/components/codecov/virtualRenderers/useIsOverflowing';
20+
import {useSyncScrollMargin} from 'sentry/components/codecov/virtualRenderers/useSyncScrollMargin';
21+
import {useSyncTotalWidth} from 'sentry/components/codecov/virtualRenderers/useSyncTotalWidth';
22+
import {useSyncWrapperWidth} from 'sentry/components/codecov/virtualRenderers/useSyncWrapperWidth';
23+
import {space} from 'sentry/styles/space';
24+
import {useLocation} from 'sentry/utils/useLocation';
25+
import {useNavigate} from 'sentry/utils/useNavigate';
26+
import type {SyntaxHighlightLine} from 'sentry/utils/usePrismTokens';
27+
import {useScrollSync} from 'sentry/utils/useScrollSync';
28+
29+
export interface LineData {
30+
baseCoverage: CoverageValue | null;
31+
baseNumber: string | null;
32+
headCoverage: CoverageValue | null;
33+
headNumber: string | null;
34+
}
35+
36+
interface CodeBodyProps {
37+
hashedPath: string;
38+
lineData: LineData[];
39+
lines: SyntaxHighlightLine[];
40+
setWrapperRefState: React.Dispatch<React.SetStateAction<HTMLDivElement | null>>;
41+
virtualizer: Virtualizer<Window, Element>;
42+
wrapperWidth: `${number}px` | '100%';
43+
}
44+
45+
function CodeBody({
46+
lines,
47+
setWrapperRefState,
48+
virtualizer,
49+
wrapperWidth,
50+
hashedPath,
51+
lineData,
52+
}: CodeBodyProps) {
53+
const location = useLocation();
54+
const navigate = useNavigate();
55+
const initializeRenderer = useRef(true);
56+
57+
useEffect(() => {
58+
if (!initializeRenderer.current) {
59+
return undefined;
60+
}
61+
initializeRenderer.current = false;
62+
63+
const lineHash = location.hash.split('-')?.[0]?.slice(1);
64+
if (lineHash === hashedPath) {
65+
const lineIndicator = location.hash.split('-')?.[1];
66+
const isBase = lineIndicator?.includes('L');
67+
const isHead = lineIndicator?.includes('R');
68+
const hashLineNumber = lineIndicator?.slice(1);
69+
70+
const index = lineData.findIndex(
71+
line =>
72+
(isHead && line.headNumber === hashLineNumber) ||
73+
(isBase && line.baseNumber === hashLineNumber)
74+
);
75+
76+
if (index >= 0 && index < lines.length) {
77+
virtualizer.scrollToIndex(index, {align: 'start'});
78+
} else {
79+
Sentry.captureMessage(
80+
`Invalid line number in diff renderer hash: ${location.hash}`,
81+
{fingerprint: ['diff-renderer-invalid-line-number']}
82+
);
83+
}
84+
}
85+
86+
return () => undefined;
87+
}, [hashedPath, lineData, lines.length, location.hash, virtualizer]);
88+
89+
return (
90+
<CodeWrapper ref={setWrapperRefState}>
91+
<LineNumberColumn>
92+
{virtualizer.getVirtualItems().map(virtualItem => {
93+
const line = lineData[virtualItem.index];
94+
const lineNumber = line?.baseNumber;
95+
const hash = `#${hashedPath}-L${lineNumber}`;
96+
97+
let coverageValue = undefined;
98+
if (lineNumber && line?.baseCoverage) {
99+
coverageValue = line?.baseCoverage;
100+
}
101+
102+
return (
103+
<LineNumber
104+
key={virtualItem.key}
105+
data-index={virtualItem.index}
106+
virtualizer={virtualizer}
107+
lineNumber={lineNumber}
108+
virtualItem={virtualItem}
109+
coverage={coverageValue}
110+
isHighlighted={location.hash === hash}
111+
onClick={() => {
112+
if (lineNumber) {
113+
location.hash = location.hash === hash ? '' : hash;
114+
navigate(location, {replace: true, preventScrollReset: true});
115+
}
116+
}}
117+
/>
118+
);
119+
})}
120+
</LineNumberColumn>
121+
<LineNumberColumn>
122+
{virtualizer.getVirtualItems().map(virtualItem => {
123+
const line = lineData[virtualItem.index];
124+
const lineNumber = line?.headNumber;
125+
const hash = `#${hashedPath}-R${lineNumber}`;
126+
127+
let coverageValue = undefined;
128+
if (lineNumber && line?.headCoverage) {
129+
coverageValue = line?.headCoverage;
130+
}
131+
132+
return (
133+
<LineNumber
134+
key={virtualItem.key}
135+
data-index={virtualItem.index}
136+
virtualizer={virtualizer}
137+
lineNumber={lineNumber}
138+
virtualItem={virtualItem}
139+
coverage={coverageValue}
140+
isHighlighted={location.hash === hash}
141+
onClick={() => {
142+
if (lineNumber) {
143+
location.hash = location.hash === hash ? '' : hash;
144+
navigate(location, {replace: true, preventScrollReset: true});
145+
}
146+
}}
147+
/>
148+
);
149+
})}
150+
</LineNumberColumn>
151+
<CodeColumn inert>
152+
{virtualizer.getVirtualItems().map(virtualItem => {
153+
const line = lineData[virtualItem.index];
154+
const baseHash = `#${hashedPath}-L${line?.baseNumber}`;
155+
const headHash = `#${hashedPath}-R${line?.headNumber}`;
156+
157+
return (
158+
<CodeLineOuterWrapper
159+
key={virtualItem.key}
160+
data-index={virtualItem.index}
161+
ref={virtualizer.measureElement}
162+
styleHeight={virtualItem.size}
163+
styleWidth={wrapperWidth}
164+
translateY={virtualItem.start - virtualizer.options.scrollMargin}
165+
>
166+
<ColorBar
167+
isHighlighted={location.hash === headHash || location.hash === baseHash}
168+
/>
169+
<CodeLineInnerWrapper>
170+
{lines[virtualItem.index]?.map((value, index) => (
171+
<span
172+
key={index}
173+
className={value.className}
174+
style={{whiteSpace: 'pre'}}
175+
>
176+
{value.children}
177+
</span>
178+
))}
179+
</CodeLineInnerWrapper>
180+
</CodeLineOuterWrapper>
181+
);
182+
})}
183+
</CodeColumn>
184+
</CodeWrapper>
185+
);
186+
}
187+
188+
const CodeWrapper = styled('div')`
189+
display: flex;
190+
`;
191+
192+
const CodeColumn = styled('div')`
193+
height: 100%;
194+
width: 100%;
195+
pointer-events: none;
196+
`;
197+
198+
const CodeLineOuterWrapper = styled('div')<{
199+
styleHeight: number;
200+
styleWidth: `${number}px` | '100%';
201+
translateY: number;
202+
}>`
203+
position: absolute;
204+
top: 0;
205+
left: 0;
206+
width: ${p => p.styleWidth};
207+
padding-left: 192px;
208+
height: ${p => p.styleHeight}px;
209+
transform: translateY(${p => p.translateY}px);
210+
display: grid;
211+
`;
212+
213+
const CodeLineInnerWrapper = styled('code')`
214+
height: ${LINE_HEIGHT}px;
215+
line-height: ${LINE_HEIGHT}px;
216+
grid-column-start: 1;
217+
grid-row-start: 1;
218+
`;
219+
220+
interface VirtualDiffRendererProps {
221+
content: string;
222+
fileName: string;
223+
hashedPath: string;
224+
lineData: LineData[];
225+
}
226+
227+
export function VirtualDiffRenderer({
228+
content,
229+
fileName,
230+
hashedPath,
231+
lineData,
232+
}: VirtualDiffRendererProps) {
233+
const widthDivRef = useRef<HTMLPreElement>(null);
234+
const codeDisplayOverlayRef = useRef<HTMLDivElement>(null);
235+
const textAreaRef = useRef<HTMLTextAreaElement>(null);
236+
const scrollBarRef = useRef<HTMLDivElement>(null);
237+
const virtualCodeRendererRef = useRef<HTMLDivElement>(null);
238+
const {wrapperWidth, setWrapperRefState} = useSyncWrapperWidth();
239+
240+
const {language, lines} = useCodeHighlighting(content, fileName);
241+
242+
const scrollMargin = useSyncScrollMargin(codeDisplayOverlayRef);
243+
244+
const virtualizer = useWindowVirtualizer({
245+
count: lines.length,
246+
scrollMargin: scrollMargin ?? 0,
247+
overscan: 60,
248+
estimateSize: () => LINE_HEIGHT,
249+
});
250+
251+
// disable pointer events while scrolling
252+
useDisablePointerEvents(virtualCodeRendererRef);
253+
254+
// sync the width of the textarea with the pushing widthDiv
255+
useSyncTotalWidth(textAreaRef, widthDivRef);
256+
257+
// check if the code display overlay is overflowing, so we can conditionally render the scroll bar
258+
const isOverflowing = useIsOverflowing(codeDisplayOverlayRef);
259+
260+
// sync text area scrolling with the code display overlay and scroll bar
261+
useScrollSync({
262+
direction: 'left',
263+
scrollingRef: textAreaRef,
264+
refsToSync: [codeDisplayOverlayRef, scrollBarRef],
265+
});
266+
267+
// sync scroll bar scrolling with the code display overlay and text area
268+
useScrollSync({
269+
direction: 'left',
270+
scrollingRef: scrollBarRef,
271+
refsToSync: [codeDisplayOverlayRef, textAreaRef],
272+
});
273+
274+
return (
275+
<VirtualCodeRenderer ref={virtualCodeRendererRef}>
276+
<TextArea
277+
ref={textAreaRef}
278+
value={content}
279+
// need to set to true since we're setting a value without an onChange handler
280+
readOnly
281+
// disable all the things for text area's so it doesn't interfere with the code display element
282+
autoCapitalize="false"
283+
autoCorrect="false"
284+
spellCheck="false"
285+
inputMode="none"
286+
aria-readonly="true"
287+
tabIndex={0}
288+
aria-multiline="true"
289+
aria-haspopup="false"
290+
/>
291+
<CodeDisplayOverlay
292+
ref={codeDisplayOverlayRef}
293+
styleHeight={virtualizer.getTotalSize()}
294+
>
295+
<CodePreWrapper
296+
ref={widthDivRef}
297+
isOverflowing={isOverflowing}
298+
className={`language-${language}`}
299+
// Need to style here as they get overridden if set in the styled component
300+
style={{
301+
padding: 0,
302+
borderTopLeftRadius: '0px',
303+
borderTopRightRadius: '0px',
304+
borderBottomLeftRadius: isOverflowing ? '0px' : space(0.75),
305+
borderBottomRightRadius: isOverflowing ? '0px' : space(0.75),
306+
}}
307+
>
308+
<CodeBody
309+
setWrapperRefState={setWrapperRefState}
310+
wrapperWidth={wrapperWidth}
311+
lines={lines}
312+
lineData={lineData}
313+
virtualizer={virtualizer}
314+
hashedPath={hashedPath}
315+
/>
316+
</CodePreWrapper>
317+
</CodeDisplayOverlay>
318+
{isOverflowing ? (
319+
<ScrollBar scrollBarRef={scrollBarRef} wrapperWidth={wrapperWidth} />
320+
) : null}
321+
</VirtualCodeRenderer>
322+
);
323+
}
324+
325+
const VirtualCodeRenderer = styled('div')`
326+
tab-size: 8;
327+
position: relative;
328+
width: 100%;
329+
overflow-x: auto;
330+
`;
331+
332+
const TextArea = styled('textarea')`
333+
tab-size: 8;
334+
overscroll-behavior-x: none;
335+
line-height: ${LINE_HEIGHT}px;
336+
scrollbar-width: none;
337+
position: absolute;
338+
padding-left: 192px;
339+
z-index: 1;
340+
width: 100%;
341+
height: 100%;
342+
resize: none;
343+
overflow-y: hidden;
344+
white-space: pre;
345+
background-color: unset;
346+
color: transparent;
347+
outline: 0px solid transparent;
348+
outline-offset: 0px;
349+
font-family: ${p => p.theme.text.familyMono};
350+
font-size: ${p => p.theme.codeFontSize};
351+
padding-top: 0;
352+
padding-bottom: 0;
353+
padding-right: 0;
354+
border: 0;
355+
`;
356+
357+
const CodeDisplayOverlay = styled('div')<{styleHeight: number}>`
358+
overflow-y: hidden;
359+
white-space: pre;
360+
overflow-x: overlay;
361+
scrollbar-width: none;
362+
position: relative;
363+
height: ${p => p.styleHeight + 2}px;
364+
`;
365+
366+
const CodePreWrapper = styled('pre')<{isOverflowing: boolean}>`
367+
width: 100%;
368+
height: 100%;
369+
scrollbar-width: none;
370+
371+
border-left: ${space(0.25)} solid ${p => p.theme.gray200};
372+
border-right: ${space(0.25)} solid ${p => p.theme.gray200};
373+
374+
${p => {
375+
if (!p.isOverflowing) {
376+
return css`
377+
border-bottom: ${space(0.25)} solid ${p.theme.gray200};
378+
`;
379+
}
380+
return '';
381+
}}
382+
`;

0 commit comments

Comments
 (0)