import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import type { ViewStyle } from "react-native"; import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; import { useToast } from "@/contexts/toast-context"; import { useStableEvent } from "@/hooks/use-stable-event"; import { InlineReviewAddButton, InlineReviewThread } from "@/review"; import { copyToClipboard } from "@/utils/copy-to-clipboard"; import type { ReviewableDiffTarget } from "@/utils/diff-layout"; import { DocumentFileHeader } from "./document-file-header"; import { diffInteractionWindowTop, diffMaterializationWindow, resolveVisibleFileSections, } from "./header-layout"; import { hitTestDiffDocument, selectAllSource, selectCellSource, selectedSourceText, } from "./hit-testing"; import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets"; import { HorizontalScroll } from "./horizontal-scroll.web"; import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model"; import { paintWebFileHeader, paintWebViewport } from "./paint.web"; import { hasPointerDragStarted } from "./pointer-gesture"; import { createMeasuredAdvances } from "./text-measurement"; import { retainDiffViewport } from "./viewport"; import type { DiffHit, DiffFileSection, DiffSelection, DiffSurfaceProps, DiffTypography, TextMeasurer, } from "./types"; import { useDiffDocumentWorkspaceCache } from "./workspace-cache"; const DEFAULT_MONO_STACK = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace"; const RESIZE_SETTLE_DELAY_MS = 120; interface StickyHeaderCanvasSlot { section: HTMLDivElement | null; canvas: HTMLCanvasElement | null; file: DiffFileSection | null; active: boolean; width: number; ratio: number; palette: DiffSurfaceProps["palette"] | null; typography: DiffSurfaceProps["headerTypography"] | null; } function emptyStickyHeaderCanvasSlot(): StickyHeaderCanvasSlot { return { section: null, canvas: null, file: null, active: false, width: 0, ratio: 0, palette: null, typography: null, }; } export function DiffSurface(props: DiffSurfaceProps) { const { t } = useTranslation(); const toast = useToast(); const workspaceCache = useDiffDocumentWorkspaceCache(); const rootRef = useRef(null); const canvasRef = useRef(null); const stickyHeaderSlotsRef = useRef<[StickyHeaderCanvasSlot, StickyHeaderCanvasSlot]>([ emptyStickyHeaderCanvasSlot(), emptyStickyHeaderCanvasSlot(), ]); const canvasScratchRef = useRef(null); const scrollRef = useRef(null); const modelRef = useRef | null>(null); const previousModelRef = useRef | null>(null); const consumedFocusRef = useRef(null); const scrollTopRef = useRef(0); const horizontalOffsetsRef = useRef(new Map()); const selectionRef = useRef(null); const dragRef = useRef<{ anchor: DiffSelection["anchor"]; startX: number; startY: number; moved: boolean; dismissSelectionOnClick: boolean; } | null>(null); const frameRef = useRef(null); const activeHeaderPathRef = useRef(null); const resizeSettleTimerRef = useRef | null>(null); const resizeReleaseFrameRef = useRef(null); const resizePointerActiveRef = useRef(false); const pendingViewportRef = useRef({ width: 0, height: 0 }); const forcePaintRef = useRef(true); const canvasWindowRef = useRef({ top: 0, height: 0 }); const [viewport, setViewport] = useState({ width: 0, height: 0 }); const [fileWindowTop, setFileWindowTop] = useState(0); const [interactionFiles, setInteractionFiles] = useState< ReturnType["files"] >([]); const [hasSelection, setHasSelection] = useState(false); const [contextHit, setContextHit] = useState | null>(null); const [hoveredAffordance, setHoveredAffordance] = useState<{ hit: Extract; left: number; top: number; } | null>(null); const hasHoveredAffordanceRef = useRef(false); const family = props.displayPreferences.monoFontFamily.trim() || DEFAULT_MONO_STACK; useLayoutEffect(() => { const stats = (window as typeof window & { __PASEO_DIFF_REACT_STATS__?: { commits: number } }) .__PASEO_DIFF_REACT_STATS__; if (stats) stats.commits += 1; }); const desiredTypography = useMemo( () => ({ family, size: props.displayPreferences.codeFontSize, lineHeight: Math.round(props.displayPreferences.codeFontSize * 1.5), }), [family, props.displayPreferences.codeFontSize], ); const typographyResource = useMemo( () => workspaceCache.typography({ typography: desiredTypography, load: async () => { await Promise.all([ document.fonts.load(`400 ${desiredTypography.size}px ${desiredTypography.family}`), document.fonts.load(`600 ${desiredTypography.size}px ${desiredTypography.family}`), document.fonts.load( `400 ${props.headerTypography.size}px ${props.headerTypography.family}`, ), document.fonts.load( `400 ${props.headerTypography.statSize}px ${props.headerTypography.family}`, ), ]); await document.fonts.ready; }, createMeasurer: () => createWebTextMeasurer(desiredTypography), }), [desiredTypography, props.headerTypography, workspaceCache], ); const [readyTypographyResource, setReadyTypographyResource] = useState | null>(() => (typographyResource.isReady() ? typographyResource : null)); const loadedTypography = readyTypographyResource === typographyResource ? typographyResource.typography : null; const measurement = readyTypographyResource === typographyResource ? typographyResource.measureText : null; const reviewActions = props.mode.kind === "working" ? props.mode.reviewActions : undefined; const model = useMemo(() => { if (!loadedTypography || !measurement) { return emptyDiffDocumentModel({ layout: props.displayPreferences.layout, wrapLines: props.displayPreferences.wrapLines, viewportWidth: viewport.width, lineHeight: desiredTypography.lineHeight, }); } const next = workspaceCache.buildModel({ files: props.files, collapsedFilePaths: props.collapsedFilePaths, layout: props.displayPreferences.layout, wrapLines: props.displayPreferences.wrapLines, viewportWidth: viewport.width, typography: loadedTypography, measureText: measurement, palette: props.palette, reviewActions, labels: { binary: t("workspace.git.diff.binaryFile"), tooLarge: t("workspace.git.diff.tooLarge"), }, materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height), }); return next; }, [ fileWindowTop, measurement, props.collapsedFilePaths, props.displayPreferences.layout, props.displayPreferences.wrapLines, props.files, props.palette, reviewActions, t, desiredTypography.lineHeight, loadedTypography, viewport.width, viewport.height, workspaceCache, ]); modelRef.current = model; const paintStickyHeaderPool = useCallback( (currentModel: ReturnType, scrollTop: number) => { const stickyFile = resolveVisibleFileSections({ files: currentModel.files, scrollTop, viewportHeight: Math.max(1, viewport.height), overscan: 0, }).sticky?.file; const pooledFiles = stickyFile ? [stickyFile, currentModel.files[stickyFile.fileIndex + 1]].filter( (file): file is DiffFileSection => file !== undefined, ) : []; const ratio = window.devicePixelRatio || 1; for (const [slotIndex, slot] of stickyHeaderSlotsRef.current.entries()) { const file = pooledFiles.find((candidate) => candidate.fileIndex % 2 === slotIndex) ?? null; const { section, canvas } = slot; if (!section || !canvas) continue; if (!file) { section.style.display = "none"; slot.file = null; continue; } section.style.display = "block"; section.style.top = `${file.top}px`; section.style.height = `${file.bottom - file.top}px`; section.dataset.diffStickyHeaderPath = file.path; const active = activeHeaderPathRef.current === file.path; const canReusePaint = slot.file === file && slot.active === active && slot.width === viewport.width && slot.ratio === ratio && slot.palette === props.palette && slot.typography === props.headerTypography; if (canReusePaint) continue; const pixelWidth = Math.ceil(viewport.width * ratio); const pixelHeight = Math.ceil(FILE_HEADER_HEIGHT * ratio); if (canvas.width !== pixelWidth) canvas.width = pixelWidth; if (canvas.height !== pixelHeight) canvas.height = pixelHeight; canvas.style.width = `${viewport.width}px`; canvas.style.height = `${FILE_HEADER_HEIGHT}px`; const context = canvas.getContext("2d"); if (!context) continue; context.setTransform(ratio, 0, 0, ratio, 0, 0); context.clearRect(0, 0, viewport.width, FILE_HEADER_HEIGHT); paintWebFileHeader({ context, file, palette: props.palette, typography: props.headerTypography, viewportWidth: viewport.width, y: 0, activePath: activeHeaderPathRef.current, }); slot.file = file; slot.active = active; slot.width = viewport.width; slot.ratio = ratio; slot.palette = props.palette; slot.typography = props.headerTypography; } }, [props.headerTypography, props.palette, viewport.height, viewport.width], ); const paint = useCallback(() => { frameRef.current = null; const canvas = canvasRef.current; const currentModel = modelRef.current; if (!canvas || !currentModel || currentModel.viewportWidth <= 0 || viewport.height <= 0) return; const desiredHeight = Math.min( Math.max(currentModel.height, viewport.height), viewport.height * 3, ); const currentWindow = canvasWindowRef.current; const viewportTop = scrollTopRef.current; const viewportBottom = viewportTop + viewport.height; const safeInset = viewport.height / 2; const mustRecenter = currentWindow.height !== desiredHeight || viewportTop < currentWindow.top + safeInset || viewportBottom > currentWindow.top + currentWindow.height - safeInset; if (!forcePaintRef.current && !mustRecenter) return; const forceFullPaint = forcePaintRef.current; forcePaintRef.current = false; const canvasHeight = desiredHeight; const ratio = window.devicePixelRatio || 1; const requestedCanvasTop = mustRecenter ? Math.min( Math.max(0, viewportTop - viewport.height), Math.max(0, currentModel.height - canvasHeight), ) : currentWindow.top; const canvasTop = Math.round(requestedCanvasTop * ratio) / ratio; canvasWindowRef.current = { top: canvasTop, height: canvasHeight }; const pixelWidth = Math.ceil(currentModel.viewportWidth * ratio); const pixelHeight = Math.ceil(canvasHeight * ratio); const resized = canvas.width !== pixelWidth || canvas.height !== pixelHeight; if (canvas.width !== pixelWidth) canvas.width = pixelWidth; if (canvas.height !== pixelHeight) canvas.height = pixelHeight; canvas.style.width = `${currentModel.viewportWidth}px`; canvas.style.top = `${canvasTop}px`; canvas.style.height = `${canvasHeight}px`; const context = canvas.getContext("2d"); if (!context) return; if (!measurement || !loadedTypography) return; const { paintTop, paintHeight } = shiftCanvasPixels({ canvas, scratch: canvasScratchRef.current ?? (canvasScratchRef.current = document.createElement("canvas")), context, currentTop: currentWindow.top, nextTop: canvasTop, canvasHeight, pixelWidth, pixelHeight, ratio, canReuse: !forceFullPaint && !resized && currentWindow.height === canvasHeight, }); paintWebViewport({ context, model: currentModel, palette: props.palette, typography: loadedTypography, headerTypography: props.headerTypography, measureText: measurement, scrollTop: canvasTop, viewportWidth: currentModel.viewportWidth, viewportHeight: canvasHeight, horizontalOffsets: horizontalOffsetsRef.current, selection: selectionRef.current, activeHeaderPath: activeHeaderPathRef.current, devicePixelRatio: ratio, paintTop, paintHeight, }); }, [loadedTypography, measurement, props.headerTypography, props.palette, viewport.height]); const schedulePaint = useCallback( (force = true) => { if (force) forcePaintRef.current = true; if (frameRef.current === null) frameRef.current = requestAnimationFrame(paint); }, [paint], ); useLayoutEffect(() => { const root = rootRef.current; if (!root) return; const commitPendingViewport = () => setViewport((current) => retainDiffViewport(current, pendingViewportRef.current)); const clearResizeSettleTimer = () => { if (resizeSettleTimerRef.current === null) return; clearTimeout(resizeSettleTimerRef.current); resizeSettleTimerRef.current = null; }; const handlePointerDown = () => { resizePointerActiveRef.current = true; clearResizeSettleTimer(); }; const handlePointerEnd = () => { if (!resizePointerActiveRef.current) return; resizePointerActiveRef.current = false; if (resizeReleaseFrameRef.current !== null) { cancelAnimationFrame(resizeReleaseFrameRef.current); } resizeReleaseFrameRef.current = requestAnimationFrame(() => { resizeReleaseFrameRef.current = null; commitPendingViewport(); }); }; const observer = new ResizeObserver(([entry]) => { if (!entry) return; const nextViewport = { width: entry.contentRect.width, height: entry.contentRect.height }; pendingViewportRef.current = nextViewport; if (resizePointerActiveRef.current) return; setViewport((currentViewport) => currentViewport.width > 0 && currentViewport.height > 0 ? currentViewport : retainDiffViewport(currentViewport, nextViewport), ); clearResizeSettleTimer(); resizeSettleTimerRef.current = setTimeout(() => { resizeSettleTimerRef.current = null; commitPendingViewport(); }, RESIZE_SETTLE_DELAY_MS); }); observer.observe(root); window.addEventListener("pointerdown", handlePointerDown, { capture: true }); window.addEventListener("pointerup", handlePointerEnd, { capture: true }); window.addEventListener("pointercancel", handlePointerEnd, { capture: true }); return () => { observer.disconnect(); clearResizeSettleTimer(); if (resizeReleaseFrameRef.current !== null) cancelAnimationFrame(resizeReleaseFrameRef.current); window.removeEventListener("pointerdown", handlePointerDown, { capture: true }); window.removeEventListener("pointerup", handlePointerEnd, { capture: true }); window.removeEventListener("pointercancel", handlePointerEnd, { capture: true }); }; }, []); useEffect(() => { let cancelled = false; if (typographyResource.isReady()) { setReadyTypographyResource(typographyResource); return; } setReadyTypographyResource(null); void typographyResource.load().then(() => { if (!cancelled) setReadyTypographyResource(typographyResource); return undefined; }); return () => { cancelled = true; }; }, [typographyResource]); useLayoutEffect(() => { const previous = previousModelRef.current; const scroll = scrollRef.current; if (previous && scroll && previous !== model) { const nextScrollTop = resolveRelayoutScrollTop(previous, model, scroll.scrollTop); scrollTopRef.current = nextScrollTop; scroll.scrollTop = nextScrollTop; } previousModelRef.current = model; }, [model]); useLayoutEffect(() => { horizontalOffsetsRef.current = retainHorizontalOffsetMapForPaths( horizontalOffsetsRef.current, props.files.map((file) => file.path), ); }, [props.files]); const updateInteractionFiles = useCallback( (scrollTop: number) => { const windowTop = diffInteractionWindowTop(scrollTop, viewport.height); setFileWindowTop((current) => (current === windowTop ? current : windowTop)); const next = resolveVisibleFileSections({ files: model.files, scrollTop: windowTop, viewportHeight: viewport.height, overscan: viewport.height * 2, }).files; setInteractionFiles((current) => current.length === next.length && current[0] === next[0] && current.at(-1) === next.at(-1) ? current : next, ); }, [model.files, viewport.height], ); useLayoutEffect(() => { schedulePaint(); paintStickyHeaderPool(model, scrollTopRef.current); updateInteractionFiles(scrollTopRef.current); }, [model, paintStickyHeaderPool, schedulePaint, updateInteractionFiles]); useEffect( () => () => { if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); // Fast Refresh preserves refs while rerunning effects. Release ownership of the canceled // request so the refreshed renderer can schedule its first paint. frameRef.current = null; }, [], ); const mode = props.mode; const collapsedFilePaths = props.collapsedFilePaths; const onToggleFile = props.onToggleFile; useEffect(() => { if (mode.kind !== "working") return; const focusPath = mode.focusPath; if (!focusPath) return; const requestKey = `${mode.focusRequestId ?? "initial"}:${focusPath}`; if (consumedFocusRef.current === requestKey) return; if (collapsedFilePaths.has(focusPath)) onToggleFile(focusPath); const file = model.files.find((entry) => entry.path === focusPath); if (file && scrollRef.current) { scrollRef.current.scrollTop = file.top; consumedFocusRef.current = requestKey; } }, [collapsedFilePaths, mode, model.files, onToggleFile]); const handleVerticalScroll = useCallback( (scrollElement: HTMLDivElement) => { const scrollTop = scrollElement.scrollTop; scrollTopRef.current = scrollTop; const clearedActiveHeader = activeHeaderPathRef.current !== null; if (clearedActiveHeader) { activeHeaderPathRef.current = null; schedulePaint(); } updateInteractionFiles(scrollTop); if (hasHoveredAffordanceRef.current) { hasHoveredAffordanceRef.current = false; setHoveredAffordance(null); } const currentModel = modelRef.current; if (currentModel) paintStickyHeaderPool(currentModel, scrollTop); const currentWindow = canvasWindowRef.current; if (!currentModel || currentWindow.height === 0) { schedulePaint(false); return; } const viewportBottom = scrollTop + viewport.height; const safeInset = viewport.height / 2; const crossedSafeInset = scrollTop < currentWindow.top + safeInset || viewportBottom > currentWindow.top + currentWindow.height - safeInset; if (!crossedSafeInset) return; const requestedTop = Math.min( Math.max(0, scrollTop - viewport.height), Math.max(0, currentModel.height - currentWindow.height), ); const ratio = window.devicePixelRatio || 1; const nextTop = Math.round(requestedTop * ratio) / ratio; if (nextTop !== currentWindow.top) schedulePaint(false); }, [paintStickyHeaderPool, schedulePaint, updateInteractionFiles, viewport.height], ); useEffect(() => { const scroll = scrollRef.current; if (!scroll) return; const onScroll = () => handleVerticalScroll(scroll); scroll.addEventListener("scroll", onScroll, { passive: true }); return () => scroll.removeEventListener("scroll", onScroll); }, [handleVerticalScroll]); const handleHorizontalScroll = useCallback( (path: string, offset: number) => { horizontalOffsetsRef.current.set(path, offset); schedulePaint(); }, [schedulePaint], ); const pointHitAt = useCallback((clientX: number, clientY: number) => { const currentModel = modelRef.current; const root = rootRef.current; if (!currentModel || !root) return null; const bounds = root.getBoundingClientRect(); const x = clientX - bounds.left; const documentY = clientY - bounds.top + scrollTopRef.current; const file = currentModel.files.find( (entry) => entry.top <= documentY && documentY < entry.bottom, ); return hitTestDiffDocument({ model: currentModel, x, documentY, horizontalOffset: file ? (horizontalOffsetsRef.current.get(file.path) ?? 0) : 0, }); }, []); const pointHit = useCallback( (event: React.PointerEvent) => pointHitAt(event.clientX, event.clientY), [pointHitAt], ); const setSelection = useStableEvent((selection: DiffSelection | null) => { selectionRef.current = selection; const currentModel = modelRef.current; setHasSelection( Boolean(selection && currentModel && selectedSourceText(currentModel, selection).length > 0), ); schedulePaint(); }); // Source and code-layout changes invalidate selection coordinates. A new // repaint callback (header typography, palette, viewport height) does not. useEffect(() => { dragRef.current = null; setSelection(null); }, [ props.collapsedFilePaths, props.displayPreferences.layout, props.files, desiredTypography, setSelection, ]); const pointerDown = useCallback( (event: React.PointerEvent) => { if (event.button !== 0) return; const target = event.target; if ( target instanceof Element && target.closest( '[data-diff-header="true"], [data-diff-review="true"], [role="menuitem"], button, input, textarea', ) ) return; const dismissSelectionOnClick = selectionRef.current !== null; if (dismissSelectionOnClick) { setSelection(null); } const hit = pointHit(event); if (hit?.kind !== "cell") return; dragRef.current = { anchor: hit.position, startX: event.clientX, startY: event.clientY, moved: false, dismissSelectionOnClick, }; setSelection({ anchor: hit.position, focus: hit.position }); event.currentTarget.focus(); event.currentTarget.setPointerCapture(event.pointerId); }, [pointHit, setSelection], ); const updateActiveHeader = useCallback( (target: EventTarget | null) => { const header = target instanceof Element ? target.closest("[data-diff-header]") : null; const activeHeaderPath = header?.dataset.diffHeaderPath ?? null; if (activeHeaderPathRef.current === activeHeaderPath) return; activeHeaderPathRef.current = activeHeaderPath; const currentModel = modelRef.current; if (currentModel) paintStickyHeaderPool(currentModel, scrollTopRef.current); schedulePaint(); }, [paintStickyHeaderPool, schedulePaint], ); const pointerMove = useCallback( (event: React.PointerEvent) => { updateActiveHeader(event.target); const drag = dragRef.current; if (drag) { drag.moved = hasPointerDragStarted({ startX: drag.startX, startY: drag.startY, x: event.clientX, y: event.clientY, alreadyDragging: drag.moved, }); } const hit = pointHit(event); if (hit?.kind === "cell") { const row = modelRef.current?.rows[hit.position.rowIndex]; const file = modelRef.current?.files[hit.position.fileIndex]; const sideIndex = row?.kind === "line" && row.cells.length === 2 && hit.position.side === "new" ? 1 : 0; if (row && file) { const columnWidth = viewport.width / (row.kind === "line" ? row.cells.length : 1); const gutterBorder = sideIndex * columnWidth + file.gutterWidth; const rootBounds = rootRef.current?.getBoundingClientRect(); const pointerX = rootBounds ? event.clientX - rootBounds.left : Number.NaN; if (hit.target && Number.isFinite(pointerX)) { hasHoveredAffordanceRef.current = true; setHoveredAffordance({ hit, left: gutterBorder - 12, top: row.top - scrollTopRef.current + (modelRef.current!.lineHeight - 22) / 2, }); } else { hasHoveredAffordanceRef.current = false; setHoveredAffordance(null); } } } else { hasHoveredAffordanceRef.current = false; setHoveredAffordance(null); } if (!drag || hit?.kind !== "cell") return; setSelection({ anchor: drag.anchor, focus: hit.position }); }, [pointHit, setSelection, updateActiveHeader, viewport.width], ); const pointerUp = useCallback( (event: React.PointerEvent) => { const drag = dragRef.current; const hit = pointHit(event); dragRef.current = null; if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } const moved = drag ? hasPointerDragStarted({ startX: drag.startX, startY: drag.startY, x: event.clientX, y: event.clientY, alreadyDragging: drag.moved, }) : false; if (drag && !moved && drag.dismissSelectionOnClick) { setSelection(null); return; } if ( event.pointerType === "touch" && drag && !moved && hit?.kind === "cell" && hit.target && reviewActions ) { setSelection(null); reviewActions.onStartComment(hit.target); } }, [pointHit, reviewActions, setSelection], ); const cancelPointer = useCallback(() => { dragRef.current = null; }, []); const pointerLeave = useCallback(() => updateActiveHeader(null), [updateActiveHeader]); const copy = useCallback( (event: React.ClipboardEvent) => { if (!selectionRef.current) return; event.clipboardData.setData("text/plain", selectedSourceText(model, selectionRef.current)); event.preventDefault(); }, [model], ); const prepareContextMenu = useCallback( (event: React.MouseEvent) => { const hit = pointHitAt(event.clientX, event.clientY); setContextHit(hit?.kind === "cell" ? hit : null); }, [pointHitAt], ); const writeSourceText = useCallback( (text: string) => { void copyToClipboard(text) .then(() => toast.copied(t("common.states.copied"))) .catch(() => toast.error(t("common.errors.unableToCopy"))); }, [t, toast], ); const copySelectedSource = useCallback(() => { const currentModel = modelRef.current; const selection = selectionRef.current; if (currentModel && selection) writeSourceText(selectedSourceText(currentModel, selection)); }, [writeSourceText]); const copyContextLine = useCallback(() => { const currentModel = modelRef.current; if (!currentModel || !contextHit) return; const selection = selectCellSource(currentModel, contextHit.position); writeSourceText(selectedSourceText(currentModel, selection)); }, [contextHit, writeSourceText]); const selectAll = useCallback(() => { const currentModel = modelRef.current; if (currentModel) { setSelection( selectAllSource(currentModel, contextHit?.position ?? selectionRef.current?.focus), ); } }, [contextHit?.position, setSelection]); const keyDown = useCallback( (event: React.KeyboardEvent) => { const target = event.target; if ( target instanceof Element && target.closest('input, textarea, [contenteditable="true"]') ) { return; } if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a") { event.preventDefault(); selectAll(); return; } if (event.key === "Escape" && selectionRef.current) { event.preventDefault(); setSelection(null); } }, [selectAll, setSelection], ); const rootStyle = useMemo( () => ({ ...ROOT_STYLE, background: props.palette.surface }), [props.palette.surface], ); const contentInsetBottom = props.contentInsetBottom ?? 0; const contentStyle = useMemo( () => ({ ...CONTENT_STYLE, height: Math.max(model.height, viewport.height) + contentInsetBottom, }), [contentInsetBottom, model.height, viewport.height], ); const affordanceStyle = useMemo( () => ({ ...AFFORDANCE_STYLE, left: hoveredAffordance?.left ?? 0, top: hoveredAffordance?.top ?? 0, }), [hoveredAffordance?.left, hoveredAffordance?.top], ); const addHoveredComment = useCallback(() => { const target = hoveredAffordance?.hit.target; if (target) reviewActions?.onStartComment(target); }, [hoveredAffordance?.hit.target, reviewActions]); const canvasStyle = useMemo( () => ({ ...CANVAS_STYLE, fontFamily: (loadedTypography ?? desiredTypography).family, fontSize: (loadedTypography ?? desiredTypography).size, }), [desiredTypography, loadedTypography], ); const surface = (
{interactionFiles.map((file) => ( ))} {interactionFiles .filter((file) => !file.isCollapsed && !model.wrapLines) .map((file) => ( ))} {props.mode.kind === "working" && reviewActions ? model.rows.map((row) => { if (row.kind !== "line" || row.reviewHeight === 0) return null; const columnWidth = model.viewportWidth / row.cells.length; return row.cells.map((cell, index) => { if (!cell?.reviewTarget) return null; const comments = reviewActions.commentsByTarget.get(cell.reviewTarget.key) ?? []; const hasEditor = reviewActions.editor?.target.filePath === cell.reviewTarget.filePath && reviewActions.editor.target.side === cell.reviewTarget.side && reviewActions.editor.target.lineNumber === cell.reviewTarget.lineNumber; if (comments.length === 0 && !hasEditor) return null; return ( ); }); }) : null}
{hoveredAffordance?.hit.target && reviewActions ? ( ) : null}
); return ( {surface} {t("common.actions.copy")} {t("common.actions.copyLine")} {t("common.actions.selectAll")} ); } function StickyHeaderCanvasSlotView({ slotsRef, slotIndex, }: { slotsRef: React.RefObject<[StickyHeaderCanvasSlot, StickyHeaderCanvasSlot]>; slotIndex: 0 | 1; }) { const setSection = useCallback( (section: HTMLDivElement | null) => { slotsRef.current[slotIndex].section = section; }, [slotIndex, slotsRef], ); const setCanvas = useCallback( (canvas: HTMLCanvasElement | null) => { slotsRef.current[slotIndex].canvas = canvas; }, [slotIndex, slotsRef], ); return (
); } function shiftCanvasPixels(input: { canvas: HTMLCanvasElement; scratch: HTMLCanvasElement; context: CanvasRenderingContext2D; currentTop: number; nextTop: number; canvasHeight: number; pixelWidth: number; pixelHeight: number; ratio: number; canReuse: boolean; }): { paintTop: number; paintHeight: number } { const delta = input.nextTop - input.currentTop; if (!input.canReuse || delta === 0 || Math.abs(delta) >= input.canvasHeight) { return { paintTop: 0, paintHeight: input.canvasHeight }; } const deltaPixels = Math.round(Math.abs(delta) * input.ratio); const overlapPixels = input.pixelHeight - deltaPixels; if (input.scratch.width !== input.pixelWidth) input.scratch.width = input.pixelWidth; if (input.scratch.height !== input.pixelHeight) input.scratch.height = input.pixelHeight; const scratchContext = input.scratch.getContext("2d"); if (!scratchContext) return { paintTop: 0, paintHeight: input.canvasHeight }; scratchContext.setTransform(1, 0, 0, 1, 0, 0); scratchContext.globalCompositeOperation = "copy"; scratchContext.drawImage(input.canvas, 0, 0); input.context.setTransform(1, 0, 0, 1, 0, 0); if (delta > 0) { input.context.drawImage( input.scratch, 0, deltaPixels, input.pixelWidth, overlapPixels, 0, 0, input.pixelWidth, overlapPixels, ); return { paintTop: input.canvasHeight - Math.abs(delta), paintHeight: Math.abs(delta) }; } input.context.drawImage( input.scratch, 0, 0, input.pixelWidth, overlapPixels, 0, deltaPixels, input.pixelWidth, overlapPixels, ); return { paintTop: 0, paintHeight: Math.abs(delta) }; } function WebFileHeaderSection({ file, children, }: { file: ReturnType["files"][number]; children: React.ReactNode; }) { const style = useMemo( () => ({ ...FILE_SECTION_STYLE, top: file.top, height: file.bottom - file.top }), [file.bottom, file.top], ); return (
{children}
); } function preventDocumentMouseSelection(event: React.MouseEvent): void { if (event.detail < 2) return; const target = event.target; if (target instanceof Element && target.closest('[data-diff-review="true"]')) return; event.preventDefault(); window.getSelection()?.removeAllRanges(); } function WebReviewThread({ target, actions, top, left, width, height, pinToViewport, }: { target: ReviewableDiffTarget; actions: NonNullable["reviewActions"]>; top: number; left: number; width: number; height: number; pinToViewport: boolean; }) { const style = useMemo( () => ({ ...REVIEW_STYLE, top, left, width, height }), [height, left, top, width], ); return (
); } function stopContextMenuPropagation(event: React.MouseEvent): void { event.stopPropagation(); } const ROOT_STYLE: React.CSSProperties = { position: "relative", flex: 1, minHeight: 0, overflow: "hidden", }; const CONTEXT_TRIGGER_STYLE: ViewStyle = { flex: 1, minHeight: 0 }; const SCROLL_STYLE: React.CSSProperties = { position: "absolute", inset: 0, zIndex: 2, overflowY: "auto", overflowX: "hidden", scrollbarWidth: "none", cursor: "text", }; function noop(): void {} const CONTENT_STYLE: React.CSSProperties = { position: "relative", width: "100%", userSelect: "none", }; const FILE_SECTION_STYLE: React.CSSProperties = { position: "absolute", left: 0, right: 0, zIndex: 3, pointerEvents: "none", userSelect: "none", }; const STICKY_CANVAS_SECTION_STYLE: React.CSSProperties = { position: "absolute", display: "none", left: 0, right: 0, zIndex: 2, pointerEvents: "none", }; const STICKY_CANVAS_STYLE: React.CSSProperties = { position: "sticky", top: 0, display: "block", pointerEvents: "none", }; const STICKY_HEADER_STYLE: React.CSSProperties = { position: "sticky", top: 0, pointerEvents: "auto", userSelect: "none", }; const BODY_MARKER_STYLE: React.CSSProperties = { position: "absolute", top: FILE_HEADER_HEIGHT, bottom: 0, left: 0, right: 0, pointerEvents: "none", }; const REVIEW_STYLE: React.CSSProperties = { position: "absolute", zIndex: 4, userSelect: "text" }; const CANVAS_STYLE: React.CSSProperties = { position: "absolute", top: 0, left: 0, zIndex: 1, pointerEvents: "none", }; const AFFORDANCE_STYLE: ViewStyle = { position: "absolute", zIndex: 5, }; function emptyDiffDocumentModel(input: { layout: "unified" | "split"; wrapLines: boolean; viewportWidth: number; lineHeight: number; }): ReturnType { return { files: [], rows: [], height: 0, lineHeight: input.lineHeight, layout: input.layout, wrapLines: input.wrapLines, viewportWidth: input.viewportWidth, reviewGeometryKey: "", }; } function createWebTextMeasurer(typography: DiffTypography): TextMeasurer { const canvas = document.createElement("canvas"); const context = canvas.getContext("2d"); if (!context) return { measure: () => 0 }; const measure = (text: string, weight: "regular" | "semibold" = "regular") => { context.font = `${weight === "semibold" ? 600 : 400} ${typography.size}px ${typography.family}`; return context.measureText(text).width; }; // Canvas exposes no glyph coverage, so `requiresShaping` alone decides which // runs a shaper has to see -- there is no `glyphIds` to fall back on. return { measure, measureAdvances: createMeasuredAdvances((text) => measure(text)) }; }