|
| 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