Prevents History file loading from getting stuck Disables inline comments in History diffs Keeps review comments available in regular diff views
1120 lines
35 KiB
TypeScript
1120 lines
35 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.
|
|
*/
|
|
export 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}
|
|
|
|
: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]);
|
|
|
|
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,
|
|
enableHoverUtility: false,
|
|
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, 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>
|
|
);
|
|
};
|