Skip to content

Commit e398cf5

Browse files
committed
✨ Add in Virtual File Renderer
1 parent c38b36c commit e398cf5

File tree

1 file changed

+307
-0
lines changed

1 file changed

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

0 commit comments

Comments
 (0)