Files
openchamber/packages/ui/src/components/views/PierreDiffViewer.tsx
T
Bohdan Triapitsyn e47c027ea0 feat(diff): unified comment UI with gutter plus and content-drag selection
The diff comment editor is restyled as the shared comment pill (context
line inside the box, auto-growing textarea, round attach button;
Cmd/Ctrl+Enter attaches, Enter breaks the line) and the saved-comment
card matches the composer preview entries. Pierre's gutter utility is
enabled: hovering a line shows a small primary-colored plus that opens
a comment for the line, dragging from it selects a range, and it rides
the bottom of an active selection. Clicking a diff line toggles a
single-line comment, and dragging over content maps to the same line
selection the number column produces — the native text selection is
suppressed once a drag crosses a line boundary — opening the editor for
the range on release. Placeholders and meta lines are dimmed relative
to typed text.
2026-08-24 01:02:33 +03:00

1324 lines
43 KiB
TypeScript

import React, { useMemo, useRef, useCallback, useEffect } from 'react';
import {
areFilesEqual,
areOptionsEqual,
FileDiff as PierreFileDiff,
VirtualizedFileDiff,
Virtualizer,
type FileContents,
type FileDiffMetadata,
type FileDiffOptions,
type DiffLineAnnotation,
type SelectedLineRange,
type AnnotationSide,
type VirtualFileMetrics,
} from '@pierre/diffs';
import {
buildPierreLineAnnotations,
type PierreAnnotationData,
PierreDiffCommentOverlays,
toPierreAnnotationId,
useInlineCommentController,
} from '@/components/comments';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useWorkerPool } from '@/contexts/DiffWorkerProvider';
import { ensurePierreThemeRegistered, getResolvedShikiTheme } from '@/lib/shiki/appThemeRegistry';
import { getDefaultTheme } from '@/lib/theme/themes';
import { useDeviceInfo } from '@/lib/device';
import { cn } from '@/lib/utils';
// Threshold (bytes) above which syntax highlighting is degraded for performance
const LARGE_CONTENT_BYTES = 500_000;
interface PierreDiffViewerProps {
original: string;
modified: string;
fileDiff?: FileDiffMetadata;
language: string;
fileName?: string;
renderSideBySide: boolean;
wrapLines?: boolean;
layout?: 'fill' | 'inline';
enableComments?: boolean;
}
/**
* Base CSS injected into Pierre's Shadow DOM. Pins font-family/size to the
* app tokens (so Files view and Diff view render at the same scale on mobile)
* and enables touch-friendly line interactions. Re-exported so plain
* <PierreFile> consumers (e.g. `MobileFilesSurface`) can inject the same.
*/
const PIERRE_RUNTIME_BASE_CSS = `
:host {
font-family: var(--font-mono);
font-size: var(--text-code);
}
pre, [data-code] {
font-family: var(--font-mono);
font-size: var(--text-code);
}
/* Mobile touch selection support */
[data-line-number] {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
cursor: pointer;
}
/* Ensure interactive line numbers work on touch */
pre[data-interactive-line-numbers] [data-line-number] {
touch-action: manipulation;
}
`;
// CSS injected into Pierre's Shadow DOM for WebKit scroll optimization +
// diff-specific separator height. Note: avoid will-change and contain:paint
// as they break resize behavior.
const WEBKIT_SCROLL_FIX_CSS = `
${PIERRE_RUNTIME_BASE_CSS}
/* While a multi-line content drag is being mapped to a line selection the
row highlight is the feedback; the native blue text selection on top of
it reads as double-selection, so it is painted transparent for the drag's
duration only (single-line selections keep the normal look for copying). */
:host([data-oc-comment-drag]) {
user-select: none;
-webkit-user-select: none;
}
/* Gutter "+" comment utility: theme primary, and smaller than Pierre's
1lh default, which reads oversized next to our 13px line numbers. */
[data-utility-button] {
width: 16px;
height: 16px;
align-self: center;
margin-right: calc(-16px + 1ch);
border-radius: 5px;
background-color: var(--primary-base);
color: var(--primary-foreground);
}
:host {
--diffs-bg-separator-override: var(--surface-elevated);
}
[data-diff-header],
[data-diff] {
[data-separator] {
height: 24px !important;
}
}
[data-separator="line-info-basic"] {
height: 24px !important;
background: var(--diffs-bg) !important;
position: relative;
}
[data-diff-type="single"] [data-gutter],
[data-diff-type="split"] [data-deletions] [data-gutter] {
[data-separator-wrapper] {
position: absolute;
left: 100%;
display: flex;
align-items: center;
gap: unset;
width: max-content;
background: transparent;
color: var(--diffs-fg-number);
font-family: var(--diffs-header-font-family, var(--font-sans));
font-size: 0.75rem;
line-height: 1;
margin-left: calc(-2ch - 2px);
}
[data-separator-wrapper][data-separator-multi-button] {
margin-left: calc(-3ch - 2px);
}
[data-expand-button],
[data-separator-content] {
display: block;
align-self: unset;
min-width: unset;
min-height: unset;
padding: 0;
flex-shrink: 0;
grid-column: unset;
border: none;
width: auto;
height: auto;
background-color: transparent;
color: inherit;
font: inherit;
}
[data-expand-button]:not([data-expand-all-button]) {
&[data-expand-down]::before {
content: '\\2191';
}
&[data-expand-up]::before {
content: '\\2193';
}
&[data-expand-both]::before {
content: '\\2195';
}
svg {
display: none;
}
}
[data-separator-content] {
background: transparent;
margin-left: calc(2px + 1ch);
}
[data-expand-all-button] {
position: relative;
margin-left: 14px;
text-transform: lowercase;
}
[data-expand-all-button]::before {
content: '';
display: block;
position: absolute;
top: 50%;
left: -8px;
margin-top: -1px;
width: 3px;
height: 3px;
border-radius: 2px;
background-color: var(--diffs-fg-number);
pointer-events: none;
}
[data-separator-content]:hover,
[data-expand-button]:hover,
[data-expand-all-button]:hover {
color: var(--diffs-fg);
}
[data-expand-all-button]:hover {
text-decoration: underline;
}
}
`;
// Fast cache key - use length + samples instead of full hash
function fnv1a32(input: string): string {
// Fast + stable across runtimes; good enough for cache keys.
let hash = 0x811c9dc5;
for (let i = 0; i < input.length; i += 1) {
hash ^= input.charCodeAt(i);
// hash *= 16777619 (but keep 32-bit)
hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
}
return hash.toString(16);
}
function makeContentCacheKey(contents: string): string {
// Avoid hashing full file; sample head+tail.
const sample = contents.length > 400
? `${contents.slice(0, 200)}${contents.slice(-200)}`
: contents;
return `${contents.length}:${fnv1a32(sample)}`;
}
const extractSelectedCode = (
original: string,
modified: string,
fileDiff: FileDiffMetadata | undefined,
range: SelectedLineRange,
): string => {
// Default to modified if side is ambiguous, as users mostly comment on new code
const isOriginal = range.side === 'deletions';
const content = fileDiff
? (isOriginal ? fileDiff.deletionLines : fileDiff.additionLines).join('')
: (isOriginal ? original : modified);
const lines = content.split('\n');
// Ensure bounds
const from = Math.min(range.start, range.end);
const to = Math.max(range.start, range.end);
const startLine = Math.max(1, from);
const endLine = Math.min(lines.length, to);
if (startLine > endLine) return '';
return lines.slice(startLine - 1, endLine).join('\n');
};
const isSameSelection = (left: SelectedLineRange | null, right: SelectedLineRange | null): boolean => {
if (left === right) return true;
if (!left || !right) return false;
return left.start === right.start && left.end === right.end && left.side === right.side;
};
const isScrollable = (value: string): boolean =>
value === 'auto' || value === 'scroll' || value === 'overlay';
const findScrollParent = (node: HTMLElement | null): HTMLElement | null => {
let current = node?.parentElement ?? null;
while (current) {
const style = window.getComputedStyle(current);
if (isScrollable(style.overflowY)) return current;
current = current.parentElement;
}
return null;
};
const preserveScrollPosition = (wrapper: HTMLElement | null, container: HTMLElement | null): (() => void) => {
if (!wrapper || !container || typeof window === 'undefined') return () => {};
const scrollParent = findScrollParent(wrapper);
if (!scrollParent) return () => {};
const height = container.getBoundingClientRect().height;
if (!height) return () => {};
const top = wrapper.getBoundingClientRect().top - scrollParent.getBoundingClientRect().top;
const previousMinHeight = container.style.minHeight;
container.style.minHeight = `${Math.ceil(height)}px`;
let done = false;
return () => {
if (done) return;
done = true;
container.style.minHeight = previousMinHeight;
const nextTop = wrapper.getBoundingClientRect().top - scrollParent.getBoundingClientRect().top;
const delta = nextTop - top;
if (delta) {
scrollParent.scrollTop += delta;
}
};
};
const waitForDiffReady = (
container: HTMLElement,
onReady: () => void,
): (() => void) => {
if (typeof window === 'undefined') return () => {};
let frameId: number | null = null;
let observer: MutationObserver | null = null;
let cancelled = false;
const finish = () => {
if (cancelled) return;
observer?.disconnect();
observer = null;
frameId = window.requestAnimationFrame(() => {
frameId = window.requestAnimationFrame(() => {
if (!cancelled) onReady();
});
});
};
const getRoot = (): ShadowRoot | undefined => {
const host = container.querySelector('diffs-container');
return host?.shadowRoot ?? undefined;
};
const isReady = (root = getRoot()) => {
return Boolean(root?.querySelector('[data-line]'));
};
if (isReady()) {
finish();
} else if (typeof MutationObserver !== 'undefined') {
observer = new MutationObserver(() => {
const root = getRoot();
if (!root) return;
if (isReady(root)) {
finish();
return;
}
observer?.disconnect();
observer = new MutationObserver(() => {
if (isReady(root)) {
finish();
}
});
observer.observe(root, { childList: true, subtree: true });
});
observer.observe(container, { childList: true, subtree: true });
} else {
frameId = window.requestAnimationFrame(finish);
}
return () => {
cancelled = true;
observer?.disconnect();
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
}
};
};
type SharedVirtualizer = {
virtualizer: Virtualizer;
root: Document | HTMLElement;
release: () => void;
};
type VirtualizerTarget = {
key: Document | HTMLElement;
root: Document | HTMLElement;
content: HTMLElement | undefined;
};
type VirtualizerEntry = {
virtualizer: Virtualizer;
refs: number;
};
const virtualizerCache = new WeakMap<Document | HTMLElement, VirtualizerEntry>();
const VIRTUAL_METRICS: Partial<VirtualFileMetrics> = {
lineHeight: 24,
hunkSeparatorHeight: 24,
spacing: 0,
};
function resolveVirtualizerTarget(container: HTMLElement): VirtualizerTarget {
const root = container.closest('[data-diff-virtual-root]');
if (root instanceof HTMLElement) {
const content = root.querySelector('[data-diff-virtual-content]');
return {
key: root,
root,
content: content instanceof HTMLElement ? content : undefined,
};
}
return {
key: document,
root: document,
content: undefined,
};
}
function acquireSharedVirtualizer(container: HTMLElement): SharedVirtualizer | null {
if (typeof document === 'undefined') return null;
const target = resolveVirtualizerTarget(container);
let entry = virtualizerCache.get(target.key);
if (!entry) {
const virtualizer = new Virtualizer();
virtualizer.setup(target.root, target.content);
entry = { virtualizer, refs: 0 };
virtualizerCache.set(target.key, entry);
}
entry.refs += 1;
let released = false;
return {
virtualizer: entry.virtualizer,
root: target.root,
release: () => {
if (released) return;
released = true;
const current = virtualizerCache.get(target.key);
if (!current) return;
current.refs -= 1;
if (current.refs > 0) return;
current.virtualizer.cleanUp();
virtualizerCache.delete(target.key);
},
};
}
const wakeVirtualizer = (
instance: PierreFileDiff<PierreAnnotationData>,
sharedVirtualizer: SharedVirtualizer | null,
forceUpdate: () => void,
): (() => void) => {
if (typeof window === 'undefined') return () => {};
const frameIds: number[] = [];
const run = () => {
try {
instance.rerender();
} catch {
// ignored
}
const root = sharedVirtualizer?.root;
if (root instanceof HTMLElement) {
root.dispatchEvent(new Event('scroll', { bubbles: false }));
} else {
document.dispatchEvent(new Event('scroll', { bubbles: false }));
}
window.dispatchEvent(new Event('resize'));
forceUpdate();
};
frameIds.push(window.requestAnimationFrame(run));
frameIds.push(window.requestAnimationFrame(() => {
frameIds.push(window.requestAnimationFrame(run));
}));
return () => {
for (const frameId of frameIds) {
window.cancelAnimationFrame(frameId);
}
};
};
export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
original,
modified,
fileDiff,
language,
fileName,
renderSideBySide,
wrapLines,
layout = 'fill',
enableComments = true,
}) => {
const themeContext = useOptionalThemeSystem();
const isDark = themeContext?.currentTheme.metadata.variant === 'dark';
const lightTheme = themeContext?.availableThemes.find(t => t.metadata.id === themeContext.lightThemeId) ?? getDefaultTheme(false);
const darkTheme = themeContext?.availableThemes.find(t => t.metadata.id === themeContext.darkThemeId) ?? getDefaultTheme(true);
const { isMobile } = useDeviceInfo();
const diffCommentController = useInlineCommentController<SelectedLineRange>({
source: 'diff',
fileLabel: fileName || 'unknown',
language,
getCodeForRange: (range) => extractSelectedCode(original, modified, fileDiff, range),
toStoreRange: (range) => ({
startLine: range.start,
endLine: range.end,
side: range.side === 'deletions' ? 'original' : 'modified',
}),
fromDraftRange: (draft) => ({
start: draft.startLine,
end: draft.endLine,
side: draft.side === 'original' ? 'deletions' : 'additions',
}),
});
const {
drafts: fileDrafts,
selection,
setSelection,
commentText,
setCommentText,
editingDraftId,
saveComment,
cancel,
startEdit,
deleteDraft,
} = diffCommentController;
const selectionRef = useRef<SelectedLineRange | null>(null);
const editingDraftIdRef = useRef<string | null>(null);
const commentTextRef = useRef('');
// Use a ref to track if we're currently applying a selection programmatically
// to avoid loop with onLineSelected callback
const isApplyingSelectionRef = useRef(false);
const lastAppliedSelectionRef = useRef<SelectedLineRange | null>(null);
useEffect(() => {
selectionRef.current = selection;
}, [selection]);
useEffect(() => {
editingDraftIdRef.current = editingDraftId;
}, [editingDraftId]);
useEffect(() => {
commentTextRef.current = commentText;
}, [commentText]);
const handleSelectionChange = useCallback((range: SelectedLineRange | null) => {
if (!enableComments) {
return;
}
// Ignore callbacks while we're programmatically applying selection
if (isApplyingSelectionRef.current) {
return;
}
const prevSelection = selectionRef.current;
if (!range && prevSelection && commentTextRef.current.trim()) {
return;
}
// Mobile tap-to-extend: if selection exists and new tap is on same side, extend range
if (isMobile && prevSelection && range && range.side === prevSelection.side) {
const start = Math.min(prevSelection.start, range.start);
const end = Math.max(prevSelection.end, range.end);
setSelection({ ...range, start, end });
} else {
setSelection(range);
}
// Clear editing state when selection changes user-driven
if (range) {
if (!editingDraftIdRef.current) {
setCommentText('');
}
}
}, [enableComments, isMobile, setCommentText, setSelection]);
const handleCancelComment = useCallback(() => {
cancel();
}, [cancel]);
const renderAnnotation = useCallback((annotation: DiffLineAnnotation<PierreAnnotationData>) => {
const div = document.createElement('div');
div.style.position = 'relative';
const id = toPierreAnnotationId(annotation.metadata);
div.dataset.annotationId = id;
div.dataset.annotationSide = annotation.side;
div.dataset.annotationLine = String(annotation.lineNumber);
return div;
}, []);
const handleSaveComment = useCallback((textToSave: string, rangeOverride?: SelectedLineRange) => {
saveComment(textToSave, rangeOverride ?? selection ?? undefined);
}, [saveComment, selection]);
const applySelection = useCallback((range: SelectedLineRange) => {
setSelection(range);
const instance = diffInstanceRef.current;
if (!instance) return;
try {
isApplyingSelectionRef.current = true;
instance.setSelectedLines(range);
lastAppliedSelectionRef.current = range;
} catch {
// ignore
} finally {
isApplyingSelectionRef.current = false;
}
}, [setSelection]);
// Multi-line text selection over diff CONTENT highlights the same line
// range Pierre paints for number-column selection — without opening the
// comment editor. The "+" utility then targets the highlighted range.
const contentSelectionRef = useRef<SelectedLineRange | null>(null);
const contentSelectionClearTimerRef = useRef<number | null>(null);
useEffect(() => {
if (!enableComments) return;
const root = diffRootRef.current;
if (!root) return;
const getShadowRoot = (): ShadowRoot | null => {
const host = root.querySelector('diffs-container');
return host instanceof HTMLElement ? host.shadowRoot : null;
};
const setDragAttribute = (active: boolean) => {
const host = root.querySelector('diffs-container');
if (!(host instanceof HTMLElement)) return;
if (active) host.setAttribute('data-oc-comment-drag', '');
else host.removeAttribute('data-oc-comment-drag');
};
const lineFromPoint = (clientX: number, clientY: number): { line: number; side: AnnotationSide; numberColumn: boolean } | null => {
const shadowRoot = getShadowRoot();
const element = shadowRoot?.elementFromPoint(clientX, clientY) ?? document.elementFromPoint(clientX, clientY);
if (!(element instanceof Element)) return null;
const numberColumn = Boolean(element.closest('[data-column-number]'));
const row = element.closest('[data-line]');
if (!(row instanceof HTMLElement)) return null;
const line = Number.parseInt(row.getAttribute('data-line') ?? '', 10);
if (!Number.isFinite(line) || line <= 0) return null;
const side: AnnotationSide = row.getAttribute('data-line-type') === 'change-deletion'
|| row.closest('[data-code][data-deletions]') != null
? 'deletions'
: 'additions';
return { line, side, numberColumn };
};
let anchor: { line: number; side: AnnotationSide } | null = null;
let engaged = false;
let pointerId: number | null = null;
const highlight = (range: SelectedLineRange) => {
contentSelectionRef.current = range;
const instance = diffInstanceRef.current;
if (!instance) return;
try {
isApplyingSelectionRef.current = true;
instance.setSelectedLines(range);
} catch {
// ignore
} finally {
isApplyingSelectionRef.current = false;
}
};
const handlePointerDown = (event: PointerEvent) => {
if (event.button !== 0 || event.pointerType !== 'mouse') return;
const hit = lineFromPoint(event.clientX, event.clientY);
// Number-column drags belong to Pierre's own selection handling.
if (!hit || hit.numberColumn) {
anchor = null;
return;
}
anchor = { line: hit.line, side: hit.side };
engaged = false;
pointerId = event.pointerId;
};
const handlePointerMove = (event: PointerEvent) => {
if (anchor == null || event.pointerId !== pointerId) return;
const hit = lineFromPoint(event.clientX, event.clientY);
if (!hit) return;
if (!engaged) {
if (hit.line === anchor.line) return;
// The drag crossed into another line: from here it is a line
// selection, not a text selection. Drop the native selection and
// block new one from forming for the rest of the drag.
engaged = true;
setDragAttribute(true);
window.getSelection()?.removeAllRanges();
const shadowRoot = getShadowRoot();
if (shadowRoot && 'getSelection' in shadowRoot) {
// SAFETY: getSelection on ShadowRoot is a Chromium extension absent
// from lib.dom; the `in` check gates the call.
(shadowRoot as ShadowRoot & { getSelection: () => Selection | null }).getSelection()?.removeAllRanges();
}
}
highlight({
start: Math.min(anchor.line, hit.line),
end: Math.max(anchor.line, hit.line),
side: anchor.side,
});
};
const handlePointerUp = (event: PointerEvent) => {
if (anchor == null || event.pointerId !== pointerId) return;
const wasEngaged = engaged;
anchor = null;
engaged = false;
pointerId = null;
setDragAttribute(false);
if (!wasEngaged) return;
const range = contentSelectionRef.current;
contentSelectionRef.current = null;
if (!range) return;
// A half-written comment survives an accidental selection elsewhere.
if (selectionRef.current && commentTextRef.current.trim() && !editingDraftIdRef.current) return;
applySelection(range);
if (!editingDraftIdRef.current) {
setCommentText('');
}
};
root.addEventListener('pointerdown', handlePointerDown);
document.addEventListener('pointermove', handlePointerMove, { passive: true });
document.addEventListener('pointerup', handlePointerUp);
return () => {
root.removeEventListener('pointerdown', handlePointerDown);
document.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerup', handlePointerUp);
setDragAttribute(false);
};
}, [applySelection, enableComments, setCommentText]);
// The gutter "+" utility: pressing it (or dragging from it) yields a line
// range; select it so the comment editor opens under the lines.
const handleGutterUtilityClick = useCallback((range: SelectedLineRange) => {
if (!enableComments) return;
// A content-drag highlight is the intended target when the pressed line
// falls inside it.
const highlighted = contentSelectionRef.current;
const withinHighlight = highlighted
&& range.start >= highlighted.start
&& range.end <= highlighted.end
&& (range.side == null || range.side === highlighted.side);
if (contentSelectionClearTimerRef.current !== null) {
window.clearTimeout(contentSelectionClearTimerRef.current);
contentSelectionClearTimerRef.current = null;
}
contentSelectionRef.current = null;
applySelection(withinHighlight && highlighted ? highlighted : range);
if (!editingDraftIdRef.current) {
setCommentText('');
}
}, [applySelection, enableComments, setCommentText]);
// Clicking anywhere on a diff line (not only its number cell) toggles a
// single-line comment selection, matching the "+" utility's target.
const handleLineClick = useCallback((props: { lineNumber: number; annotationSide: AnnotationSide; numberColumn: boolean }) => {
if (!enableComments || props.numberColumn) return;
// Ignore when the user selected text on the way to this click (copying
// code must not pop the comment editor).
if (window.getSelection()?.toString().trim()) return;
const side: SelectedLineRange['side'] = props.annotationSide;
const range: SelectedLineRange = { start: props.lineNumber, end: props.lineNumber, side };
const current = selectionRef.current;
if (current && current.start === range.start && current.end === range.end && current.side === range.side) {
if (!commentTextRef.current.trim()) {
setSelection(null);
const instance = diffInstanceRef.current;
try {
isApplyingSelectionRef.current = true;
instance?.setSelectedLines(null);
} finally {
isApplyingSelectionRef.current = false;
}
}
return;
}
if (current && commentTextRef.current.trim() && !editingDraftIdRef.current) {
// A half-written comment survives an accidental click elsewhere.
return;
}
applySelection(range);
if (!editingDraftIdRef.current) {
setCommentText('');
}
}, [applySelection, enableComments, setCommentText, setSelection]);
const resolveClickedSide = useCallback((numberCell: HTMLElement): AnnotationSide => {
const lineType =
numberCell.closest('[data-line-type]')?.getAttribute('data-line-type')
?? numberCell.getAttribute('data-line-type');
if (lineType === 'change-deletion') {
return 'deletions';
}
if (lineType === 'change-addition') {
return 'additions';
}
const explicitColumnSide =
numberCell.getAttribute('data-column-side')
?? numberCell.getAttribute('data-side')
?? numberCell.closest('[data-column-side]')?.getAttribute('data-column-side');
if (explicitColumnSide === 'deletions' || explicitColumnSide === 'left' || explicitColumnSide === 'original') {
return 'deletions';
}
if (explicitColumnSide === 'additions' || explicitColumnSide === 'right' || explicitColumnSide === 'modified') {
return 'additions';
}
const row = numberCell.closest('[data-line-type]');
if (row instanceof HTMLElement) {
const rowRect = row.getBoundingClientRect();
const cellRect = numberCell.getBoundingClientRect();
const rowCenter = rowRect.left + rowRect.width / 2;
const cellCenter = cellRect.left + cellRect.width / 2;
return cellCenter < rowCenter ? 'deletions' : 'additions';
}
return 'additions';
}, []);
ensurePierreThemeRegistered(lightTheme);
ensurePierreThemeRegistered(darkTheme);
const diffThemeKey = `${lightTheme.metadata.id}:${darkTheme.metadata.id}:${isDark ? 'dark' : 'light'}`;
const isLargeContent = useMemo(() => {
if (fileDiff) {
const deletionLength = fileDiff.deletionLines.reduce((total, line) => total + line.length, 0);
const additionLength = fileDiff.additionLines.reduce((total, line) => total + line.length, 0);
return Math.max(deletionLength, additionLength) > LARGE_CONTENT_BYTES;
}
return Math.max(original.length, modified.length) > LARGE_CONTENT_BYTES;
}, [fileDiff, modified.length, original.length]);
const diffRootRef = useRef<HTMLDivElement | null>(null);
const diffContainerRef = useRef<HTMLDivElement | null>(null);
const diffInstanceRef = useRef<PierreFileDiff<PierreAnnotationData> | null>(null);
const sharedVirtualizerRef = useRef<SharedVirtualizer | null>(null);
const instanceVirtualizerRef = useRef<Virtualizer | null>(null);
const instanceWorkerPoolRef = useRef<unknown>(null);
const instanceVirtualHunkSeparatorsRef = useRef<FileDiffOptions<PierreAnnotationData>['hunkSeparators'] | undefined>(undefined);
const instanceFileDiffRef = useRef<FileDiffMetadata | undefined>(undefined);
const instanceOldFileRef = useRef<FileContents | undefined>(undefined);
const instanceNewFileRef = useRef<FileContents | undefined>(undefined);
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
const workerPool = useWorkerPool(isLargeContent ? 'unified' : (renderSideBySide ? 'split' : 'unified'));
const lightResolvedTheme = useMemo(() => getResolvedShikiTheme(lightTheme), [lightTheme]);
const darkResolvedTheme = useMemo(() => getResolvedShikiTheme(darkTheme), [darkTheme]);
// Fast-path: update base diff theme vars immediately.
// Without this, already-mounted diffs can keep old bg/bars until async highlight completes.
React.useLayoutEffect(() => {
const root = diffRootRef.current;
if (!root) return;
const container = root.querySelector('diffs-container') as HTMLElement | null;
if (!container) return;
const currentResolved = isDark ? darkResolvedTheme : lightResolvedTheme;
const getColor = (
resolved: typeof currentResolved,
key: string,
): string | undefined => {
const colors = resolved.colors as Record<string, string> | undefined;
return colors?.[key];
};
const lightAdd = getColor(lightResolvedTheme, 'terminal.ansiGreen');
const lightDel = getColor(lightResolvedTheme, 'terminal.ansiRed');
const lightMod = getColor(lightResolvedTheme, 'terminal.ansiBlue');
const darkAdd = getColor(darkResolvedTheme, 'terminal.ansiGreen');
const darkDel = getColor(darkResolvedTheme, 'terminal.ansiRed');
const darkMod = getColor(darkResolvedTheme, 'terminal.ansiBlue');
// Apply on host; vars inherit into shadow root.
container.style.setProperty('--shiki-light', lightResolvedTheme.fg);
container.style.setProperty('--shiki-light-bg', lightResolvedTheme.bg);
if (lightAdd) container.style.setProperty('--shiki-light-addition-color', lightAdd);
if (lightDel) container.style.setProperty('--shiki-light-deletion-color', lightDel);
if (lightMod) container.style.setProperty('--shiki-light-modified-color', lightMod);
container.style.setProperty('--shiki-dark', darkResolvedTheme.fg);
container.style.setProperty('--shiki-dark-bg', darkResolvedTheme.bg);
if (darkAdd) container.style.setProperty('--shiki-dark-addition-color', darkAdd);
if (darkDel) container.style.setProperty('--shiki-dark-deletion-color', darkDel);
if (darkMod) container.style.setProperty('--shiki-dark-modified-color', darkMod);
container.style.setProperty('--diffs-bg', currentResolved.bg);
container.style.setProperty('--diffs-fg', currentResolved.fg);
const currentAdd = isDark ? darkAdd : lightAdd;
const currentDel = isDark ? darkDel : lightDel;
const currentMod = isDark ? darkMod : lightMod;
if (currentAdd) container.style.setProperty('--diffs-addition-color-override', currentAdd);
if (currentDel) container.style.setProperty('--diffs-deletion-color-override', currentDel);
if (currentMod) container.style.setProperty('--diffs-modified-color-override', currentMod);
// Pierre also inlines theme styles on <pre> inside shadow root.
// Patch it too so already-expanded diffs switch instantly.
const pre = container.shadowRoot?.querySelector('pre') as HTMLPreElement | null;
if (pre) {
pre.style.setProperty('--shiki-light', lightResolvedTheme.fg);
pre.style.setProperty('--shiki-light-bg', lightResolvedTheme.bg);
if (lightAdd) pre.style.setProperty('--shiki-light-addition-color', lightAdd);
if (lightDel) pre.style.setProperty('--shiki-light-deletion-color', lightDel);
if (lightMod) pre.style.setProperty('--shiki-light-modified-color', lightMod);
pre.style.setProperty('--shiki-dark', darkResolvedTheme.fg);
pre.style.setProperty('--shiki-dark-bg', darkResolvedTheme.bg);
if (darkAdd) pre.style.setProperty('--shiki-dark-addition-color', darkAdd);
if (darkDel) pre.style.setProperty('--shiki-dark-deletion-color', darkDel);
if (darkMod) pre.style.setProperty('--shiki-dark-modified-color', darkMod);
pre.style.setProperty('--diffs-bg', currentResolved.bg);
pre.style.setProperty('--diffs-fg', currentResolved.fg);
if (currentAdd) pre.style.setProperty('--diffs-addition-color-override', currentAdd);
if (currentDel) pre.style.setProperty('--diffs-deletion-color-override', currentDel);
if (currentMod) pre.style.setProperty('--diffs-modified-color-override', currentMod);
}
}, [darkResolvedTheme, diffThemeKey, isDark, lightResolvedTheme]);
const options = useMemo(() => ({
theme: {
dark: darkTheme.metadata.id,
light: lightTheme.metadata.id,
},
themeType: isDark ? ('dark' as const) : ('light' as const),
diffStyle: renderSideBySide ? ('split' as const) : ('unified' as const),
diffIndicators: 'none' as const,
hunkSeparators: 'line-info-basic' as const,
// Perf: disable intra-line diff (word-level) globally.
lineDiffType: 'none' as const,
// Perf: degrade tokenization/highlighting for large files (>500KB)
maxLineDiffLength: isLargeContent ? 0 : 1000,
maxLineLengthForHighlighting: isLargeContent ? 1 : 1000,
tokenizeMaxLineLength: isLargeContent ? 1 : 1000,
collapsedContextThreshold: 0,
expansionLineCount: 20,
overflow: wrapLines ? ('wrap' as const) : ('scroll' as const),
disableFileHeader: true,
enableLineSelection: enableComments,
enableGutterUtility: enableComments,
onGutterUtilityClick: enableComments ? handleGutterUtilityClick : undefined,
onLineClick: enableComments ? handleLineClick : undefined,
onLineSelected: enableComments ? handleSelectionChange : undefined,
unsafeCSS: WEBKIT_SCROLL_FIX_CSS,
renderAnnotation: enableComments ? renderAnnotation : undefined,
}), [darkTheme.metadata.id, enableComments, isDark, isLargeContent, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange, handleGutterUtilityClick, handleLineClick, renderAnnotation]);
const lineAnnotations = useMemo(() => {
if (!enableComments) {
return [];
}
return buildPierreLineAnnotations({
drafts: fileDrafts,
editingDraftId,
selection,
});
}, [editingDraftId, enableComments, fileDrafts, selection]);
const lineAnnotationsRef = useRef(lineAnnotations);
useEffect(() => {
lineAnnotationsRef.current = lineAnnotations;
}, [lineAnnotations]);
useEffect(() => {
const container = diffContainerRef.current;
return () => {
diffInstanceRef.current?.cleanUp();
diffInstanceRef.current = null;
sharedVirtualizerRef.current?.release();
sharedVirtualizerRef.current = null;
instanceVirtualizerRef.current = null;
instanceWorkerPoolRef.current = null;
instanceVirtualHunkSeparatorsRef.current = undefined;
instanceFileDiffRef.current = undefined;
instanceOldFileRef.current = undefined;
instanceNewFileRef.current = undefined;
if (container) {
container.innerHTML = '';
}
};
}, []);
useEffect(() => {
if (typeof window === 'undefined') return;
const container = diffContainerRef.current;
const wrapper = diffRootRef.current;
if (!container) return;
if (!workerPool) return;
const preserveDone = preserveScrollPosition(wrapper, container);
let sharedVirtualizer = sharedVirtualizerRef.current;
if (!sharedVirtualizer) {
sharedVirtualizer = acquireSharedVirtualizer(container);
sharedVirtualizerRef.current = sharedVirtualizer;
}
sharedVirtualizerRef.current = sharedVirtualizer;
const virtualizer = sharedVirtualizer?.virtualizer ?? null;
const oldFile: FileContents | undefined = fileDiff ? undefined : {
name: fileName || '',
contents: original,
lang: language as FileContents['lang'],
cacheKey: `old:${diffThemeKey}:${fileName}:${makeContentCacheKey(original)}`,
};
const newFile: FileContents | undefined = fileDiff ? undefined : {
name: fileName || '',
contents: modified,
lang: language as FileContents['lang'],
cacheKey: `new:${diffThemeKey}:${fileName}:${makeContentCacheKey(modified)}`,
};
const targetChanged = fileDiff
? instanceFileDiffRef.current !== fileDiff
: instanceFileDiffRef.current !== undefined
|| !oldFile
|| !newFile
|| !instanceOldFileRef.current
|| !instanceNewFileRef.current
|| !areFilesEqual(instanceOldFileRef.current, oldFile)
|| !areFilesEqual(instanceNewFileRef.current, newFile);
const currentInstance = diffInstanceRef.current;
const shouldReset = Boolean(
currentInstance
&& (
instanceVirtualizerRef.current !== virtualizer
|| instanceWorkerPoolRef.current !== workerPool
|| (virtualizer && (instanceVirtualHunkSeparatorsRef.current !== options.hunkSeparators || targetChanged))
)
);
if (shouldReset) {
currentInstance?.cleanUp();
diffInstanceRef.current = null;
container.innerHTML = '';
}
let instance = diffInstanceRef.current;
const forceRender = !shouldReset && currentInstance
? !areOptionsEqual(currentInstance.options, options)
: false;
if (!instance) {
instance = sharedVirtualizer
? new VirtualizedFileDiff<PierreAnnotationData>(
options as FileDiffOptions<PierreAnnotationData>,
sharedVirtualizer.virtualizer,
VIRTUAL_METRICS,
workerPool,
)
: new PierreFileDiff(options as FileDiffOptions<PierreAnnotationData>, workerPool);
diffInstanceRef.current = instance;
lastAppliedSelectionRef.current = null;
} else {
instance.setOptions(options as FileDiffOptions<PierreAnnotationData>);
}
instanceVirtualizerRef.current = virtualizer;
instanceWorkerPoolRef.current = workerPool;
instanceVirtualHunkSeparatorsRef.current = virtualizer ? options.hunkSeparators : undefined;
instanceFileDiffRef.current = fileDiff;
instanceOldFileRef.current = oldFile;
instanceNewFileRef.current = newFile;
if (fileDiff) {
instance.render({
fileDiff,
forceRender,
lineAnnotations: lineAnnotationsRef.current,
containerWrapper: container,
});
} else {
if (!oldFile || !newFile) return;
instance.render({
oldFile,
newFile,
forceRender,
lineAnnotations: lineAnnotationsRef.current,
containerWrapper: container,
});
}
const cancelReady = waitForDiffReady(container, () => {
preserveDone();
wakeVirtualizer(instance, sharedVirtualizer, forceUpdate);
});
return () => {
cancelReady();
preserveDone();
};
}, [diffThemeKey, fileDiff, fileName, language, modified, options, original, workerPool]);
useEffect(() => {
const instance = diffInstanceRef.current;
if (!instance) return;
try {
instance.setLineAnnotations(lineAnnotations);
} catch (error) {
console.error('Failed to apply diff line annotations', error);
try {
instance.setLineAnnotations([]);
} catch {
// ignored
}
}
requestAnimationFrame(() => {
if (diffInstanceRef.current !== instance) return;
try {
instance.rerender();
} catch (err) {
void err;
}
forceUpdate();
});
}, [lineAnnotations]);
useEffect(() => {
const instance = diffInstanceRef.current;
if (!instance) return;
// Only push selection to the diff when clearing.
// User-driven selections already originate from the diff itself.
if (selection !== null) {
return;
}
// Guard against feedback loops and redundant updates
const lastApplied = lastAppliedSelectionRef.current;
if (isSameSelection(selection, lastApplied)) {
return;
}
try {
isApplyingSelectionRef.current = true;
instance.setSelectedLines(selection);
lastAppliedSelectionRef.current = selection;
} catch {
// ignore
} finally {
isApplyingSelectionRef.current = false;
}
}, [selection]);
useEffect(() => {
if (!enableComments) return;
const container = diffContainerRef.current;
if (!container) return;
let rafId: number | null = null;
let cleanup = () => {};
const setup = () => {
const host = container.querySelector('diffs-container');
const shadowRoot = host?.shadowRoot;
if (!shadowRoot) {
rafId = requestAnimationFrame(setup);
return;
}
const onClickCapture = (event: Event) => {
if (!(event instanceof MouseEvent) || event.button !== 0) return;
if (!(event.target instanceof Element)) return;
const numberCell = event.target.closest('[data-column-number]');
if (!(numberCell instanceof HTMLElement)) return;
const lineRaw = numberCell.getAttribute('data-column-number');
const lineNumber = lineRaw ? parseInt(lineRaw, 10) : NaN;
if (Number.isNaN(lineNumber)) return;
const side = resolveClickedSide(numberCell);
handleSelectionChange({
start: lineNumber,
end: lineNumber,
side,
});
event.preventDefault();
event.stopPropagation();
};
shadowRoot.addEventListener('click', onClickCapture, true);
cleanup = () => {
shadowRoot.removeEventListener('click', onClickCapture, true);
};
};
setup();
return () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
cleanup();
};
}, [diffThemeKey, enableComments, fileName, handleSelectionChange, resolveClickedSide]);
// MutationObserver to trigger re-renders when annotation DOM nodes are added/removed
useEffect(() => {
const container = diffContainerRef.current;
if (!container) return;
let observer: MutationObserver | null = null;
let rafId: number | null = null;
const setupObserver = () => {
const diffsContainer = container.querySelector('diffs-container');
if (!(diffsContainer instanceof HTMLElement)) return;
const shadowRoot = diffsContainer.shadowRoot;
observer = new MutationObserver(() => {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
forceUpdate();
rafId = null;
});
});
observer.observe(diffsContainer, { childList: true, subtree: true });
if (shadowRoot) {
observer.observe(shadowRoot, { childList: true, subtree: true });
}
};
const timeoutId = setTimeout(setupObserver, 100);
return () => {
clearTimeout(timeoutId);
if (rafId) cancelAnimationFrame(rafId);
observer?.disconnect();
};
}, [diffThemeKey, fileName]);
if (typeof window === 'undefined') {
return null;
}
const commentOverlays = enableComments ? (
<PierreDiffCommentOverlays
diffRootRef={diffRootRef}
drafts={fileDrafts}
selection={selection}
editingDraftId={editingDraftId}
commentText={commentText}
onTextChange={setCommentText}
fileLabel={(fileName?.split('/').pop()) ?? ''}
onSave={handleSaveComment}
onCancel={handleCancelComment}
onEdit={(draft) => {
applySelection({
start: draft.startLine,
end: draft.endLine,
side: draft.side === 'original' ? 'deletions' : 'additions',
});
startEdit(draft);
}}
onDelete={deleteDraft}
/>
) : null;
if (layout === 'fill') {
return (
<div className={cn("flex flex-col relative", "size-full")} data-diff-virtual-root>
<div className="flex-1 relative min-h-0">
<ScrollableOverlay
outerClassName="pierre-diff-wrapper size-full"
disableHorizontal={false}
fillContainer={true}
data-diff-virtual-content
>
<div ref={diffRootRef} className="size-full relative">
<div ref={diffContainerRef} className="size-full" />
</div>
</ScrollableOverlay>
{commentOverlays}
</div>
</div>
);
}
// Fallback for 'inline' layout
return (
<div className={cn("relative", "w-full")}>
<div ref={diffRootRef} className="pierre-diff-wrapper w-full overflow-x-auto overflow-y-visible relative">
<div ref={diffContainerRef} className="w-full" />
</div>
{commentOverlays}
</div>
);
};