Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/components/RightSidebar/CodeReview/HunkViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,48 @@ export const HunkViewer = React.memo<HunkViewerProps>(
onReviewNote,
searchConfig,
}) => {
// Ref for the hunk container to track visibility
const hunkRef = React.useRef<HTMLDivElement>(null);

// Track if hunk is visible in viewport for lazy syntax highlighting
// Use ref for visibility to avoid re-renders when visibility changes
const isVisibleRef = React.useRef(true); // Start visible to avoid flash
const [isVisible, setIsVisible] = React.useState(true);

// Use IntersectionObserver to track visibility
React.useEffect(() => {
const element = hunkRef.current;
if (!element) return;

// Create observer with generous root margin for pre-loading
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const newVisibility = entry.isIntersecting;
// Only trigger re-render if transitioning from not-visible to visible
// (to start highlighting). Transitions from visible to not-visible don't
// need re-render because we cache the highlighting result.
if (newVisibility && !isVisibleRef.current) {
isVisibleRef.current = true;
setIsVisible(true);
} else if (!newVisibility && isVisibleRef.current) {
isVisibleRef.current = false;
// Don't update state when going invisible - keeps highlighted version
}
});
},
{
rootMargin: "600px", // Pre-load hunks 600px before they enter viewport
}
);

observer.observe(element);

return () => {
observer.disconnect();
};
}, []);

// Parse diff lines (memoized - only recompute if hunk.content changes)
// Must be done before state initialization to determine initial collapse state
const { lineCount, additions, deletions, isLargeHunk } = React.useMemo(() => {
Expand Down Expand Up @@ -137,6 +179,7 @@ export const HunkViewer = React.memo<HunkViewerProps>(

return (
<div
ref={hunkRef}
className={cn(
"bg-dark border rounded mb-3 overflow-hidden cursor-pointer transition-all duration-200",
"focus:outline-none focus-visible:outline-none",
Expand Down Expand Up @@ -215,6 +258,7 @@ export const HunkViewer = React.memo<HunkViewerProps>(
onClick?.(syntheticEvent);
}}
searchConfig={searchConfig}
enableHighlighting={isVisible}
/>
</div>
) : (
Expand Down
25 changes: 24 additions & 1 deletion src/components/shared/DiffRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ interface DiffRendererProps {
/**
* Hook to pre-process and highlight diff content in chunks
* Runs once when content/language changes (NOT search - that's applied post-process)
*
* CACHING: Once highlighted with real language, result is cached even if enableHighlighting
* becomes false later. This prevents re-highlighting during scroll when hunks leave viewport.
*/
function useHighlightedDiff(
content: string,
Expand All @@ -118,8 +121,15 @@ function useHighlightedDiff(
newStart: number
): HighlightedChunk[] | null {
const [chunks, setChunks] = useState<HighlightedChunk[] | null>(null);
// Track if we've already highlighted with real syntax (to prevent downgrading)
const hasHighlightedRef = React.useRef(false);

useEffect(() => {
// If already highlighted and trying to switch to plain text, keep the highlighted version
if (hasHighlightedRef.current && language === "text") {
return; // Keep cached highlighted chunks
}

let cancelled = false;

async function highlight() {
Expand All @@ -136,6 +146,10 @@ function useHighlightedDiff(

if (!cancelled) {
setChunks(highlighted);
// Mark as highlighted if using real language (not plain text)
if (language !== "text") {
hasHighlightedRef.current = true;
}
}
}

Expand Down Expand Up @@ -260,6 +274,8 @@ interface SelectableDiffRendererProps extends Omit<DiffRendererProps, "filePath"
onLineClick?: () => void;
/** Search highlight configuration (optional) */
searchConfig?: SearchHighlightConfig;
/** Enable syntax highlighting (default: true). Set to false to skip highlighting for off-screen hunks */
enableHighlighting?: boolean;
}

interface LineSelection {
Expand Down Expand Up @@ -386,6 +402,7 @@ export const SelectableDiffRenderer = React.memo<SelectableDiffRendererProps>(
onReviewNote,
onLineClick,
searchConfig,
enableHighlighting = true,
}) => {
const [selection, setSelection] = React.useState<LineSelection | null>(null);

Expand All @@ -395,7 +412,13 @@ export const SelectableDiffRenderer = React.memo<SelectableDiffRendererProps>(
[filePath]
);

const highlightedChunks = useHighlightedDiff(content, language, oldStart, newStart);
// Only highlight if enabled (for viewport optimization)
const highlightedChunks = useHighlightedDiff(
content,
enableHighlighting ? language : "text",
oldStart,
newStart
);

// Build lineData from highlighted chunks (memoized to prevent repeated parsing)
// Note: content field is NOT included - must be extracted from lines array when needed
Expand Down