2026-02-20 18:20:33 -03:00
|
|
|
import React, { useMemo, useRef, useCallback, useEffect } from 'react';
|
2026-02-13 19:05:31 +02:00
|
|
|
import {
|
2026-06-13 23:55:50 +03:00
|
|
|
areFilesEqual,
|
|
|
|
|
areOptionsEqual,
|
2026-02-13 19:05:31 +02:00
|
|
|
FileDiff as PierreFileDiff,
|
|
|
|
|
VirtualizedFileDiff,
|
|
|
|
|
Virtualizer,
|
|
|
|
|
type FileContents,
|
2026-06-13 23:55:50 +03:00
|
|
|
type FileDiffMetadata,
|
2026-02-13 19:05:31 +02:00
|
|
|
type FileDiffOptions,
|
|
|
|
|
type DiffLineAnnotation,
|
2026-02-20 18:20:33 -03:00
|
|
|
type SelectedLineRange,
|
2026-02-13 19:05:31 +02:00
|
|
|
type AnnotationSide,
|
|
|
|
|
type VirtualFileMetrics,
|
|
|
|
|
} from '@pierre/diffs';
|
2026-02-20 18:20:33 -03:00
|
|
|
import {
|
|
|
|
|
buildPierreLineAnnotations,
|
|
|
|
|
type PierreAnnotationData,
|
|
|
|
|
PierreDiffCommentOverlays,
|
|
|
|
|
toPierreAnnotationId,
|
|
|
|
|
useInlineCommentController,
|
|
|
|
|
} from '@/components/comments';
|
2025-12-14 02:29:46 +02:00
|
|
|
|
|
|
|
|
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
|
|
|
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
2026-02-01 20:53:00 +02:00
|
|
|
import { useWorkerPool } from '@/contexts/DiffWorkerProvider';
|
2026-02-01 18:29:34 +02:00
|
|
|
import { ensurePierreThemeRegistered, getResolvedShikiTheme } from '@/lib/shiki/appThemeRegistry';
|
|
|
|
|
import { getDefaultTheme } from '@/lib/theme/themes';
|
2025-12-14 02:29:46 +02:00
|
|
|
|
2026-01-15 18:59:23 +02:00
|
|
|
import { useDeviceInfo } from '@/lib/device';
|
2026-02-08 10:42:50 -03:00
|
|
|
import { cn } from '@/lib/utils';
|
2026-01-15 18:59:23 +02:00
|
|
|
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
// Threshold (bytes) above which syntax highlighting is degraded for performance
|
|
|
|
|
const LARGE_CONTENT_BYTES = 500_000;
|
|
|
|
|
|
2025-12-14 02:29:46 +02:00
|
|
|
interface PierreDiffViewerProps {
|
|
|
|
|
original: string;
|
|
|
|
|
modified: string;
|
2026-06-13 23:55:50 +03:00
|
|
|
fileDiff?: FileDiffMetadata;
|
2025-12-14 02:29:46 +02:00
|
|
|
language: string;
|
|
|
|
|
fileName?: string;
|
|
|
|
|
renderSideBySide: boolean;
|
|
|
|
|
wrapLines?: boolean;
|
2026-01-14 10:15:16 -03:00
|
|
|
layout?: 'fill' | 'inline';
|
2025-12-14 02:29:46 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
/**
|
|
|
|
|
* 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 = `
|
2025-12-16 13:26:03 +02:00
|
|
|
:host {
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
font-size: var(--text-code);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pre, [data-code] {
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
font-size: var(--text-code);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 18:59:23 +02:00
|
|
|
/* Mobile touch selection support */
|
|
|
|
|
[data-line-number] {
|
|
|
|
|
touch-action: manipulation;
|
|
|
|
|
-webkit-tap-highlight-color: transparent;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
}
|
2026-02-01 18:29:34 +02:00
|
|
|
|
2026-01-15 18:59:23 +02:00
|
|
|
/* Ensure interactive line numbers work on touch */
|
|
|
|
|
pre[data-interactive-line-numbers] [data-line-number] {
|
|
|
|
|
touch-action: manipulation;
|
|
|
|
|
}
|
2026-06-02 00:43:05 +03:00
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
// 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}
|
2026-02-28 01:20:24 +02:00
|
|
|
|
2026-06-13 22:30:03 +03:00
|
|
|
:host {
|
|
|
|
|
--diffs-bg-separator-override: var(--surface-elevated);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 14:15:19 +02:00
|
|
|
[data-diff-header],
|
|
|
|
|
[data-diff] {
|
|
|
|
|
[data-separator] {
|
|
|
|
|
height: 24px !important;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`;
|
2025-12-14 02:29:46 +02:00
|
|
|
|
|
|
|
|
// Fast cache key - use length + samples instead of full hash
|
2026-02-01 20:53:00 +02:00
|
|
|
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)}`;
|
2025-12-14 02:29:46 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-13 23:55:50 +03:00
|
|
|
const extractSelectedCode = (
|
|
|
|
|
original: string,
|
|
|
|
|
modified: string,
|
|
|
|
|
fileDiff: FileDiffMetadata | undefined,
|
|
|
|
|
range: SelectedLineRange,
|
|
|
|
|
): string => {
|
2026-01-15 18:59:23 +02:00
|
|
|
// Default to modified if side is ambiguous, as users mostly comment on new code
|
|
|
|
|
const isOriginal = range.side === 'deletions';
|
2026-06-13 23:55:50 +03:00
|
|
|
const content = fileDiff
|
|
|
|
|
? (isOriginal ? fileDiff.deletionLines : fileDiff.additionLines).join('')
|
|
|
|
|
: (isOriginal ? original : modified);
|
2026-01-15 18:59:23 +02:00
|
|
|
const lines = content.split('\n');
|
2026-02-01 18:29:34 +02:00
|
|
|
|
2026-01-15 18:59:23 +02:00
|
|
|
// Ensure bounds
|
2026-02-13 19:05:31 +02:00
|
|
|
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);
|
2026-02-01 18:29:34 +02:00
|
|
|
|
2026-01-15 18:59:23 +02:00
|
|
|
if (startLine > endLine) return '';
|
2026-02-01 18:29:34 +02:00
|
|
|
|
2026-01-15 18:59:23 +02:00
|
|
|
return lines.slice(startLine - 1, endLine).join('\n');
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-03 15:05:01 -03:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-13 23:55:50 +03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-13 19:05:31 +02:00
|
|
|
type SharedVirtualizer = {
|
|
|
|
|
virtualizer: Virtualizer;
|
2026-06-13 23:55:50 +03:00
|
|
|
root: Document | HTMLElement;
|
2026-02-13 19:05:31 +02:00
|
|
|
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,
|
|
|
|
|
fileGap: 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,
|
2026-06-13 23:55:50 +03:00
|
|
|
root: target.root,
|
2026-02-13 19:05:31 +02:00
|
|
|
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);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-13 23:55:50 +03:00
|
|
|
const wakeVirtualizer = (
|
|
|
|
|
instance: PierreFileDiff<unknown>,
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-14 02:29:46 +02:00
|
|
|
export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|
|
|
|
original,
|
|
|
|
|
modified,
|
2026-06-13 23:55:50 +03:00
|
|
|
fileDiff,
|
2025-12-14 02:29:46 +02:00
|
|
|
language,
|
2026-02-08 10:42:50 -03:00
|
|
|
fileName,
|
2025-12-14 02:29:46 +02:00
|
|
|
renderSideBySide,
|
2026-02-08 10:42:50 -03:00
|
|
|
wrapLines,
|
2026-01-14 10:15:16 -03:00
|
|
|
layout = 'fill',
|
2025-12-14 02:29:46 +02:00
|
|
|
}) => {
|
2026-02-08 10:42:50 -03:00
|
|
|
const themeContext = useOptionalThemeSystem();
|
2026-02-28 01:20:24 +02:00
|
|
|
|
2026-02-09 19:13:55 +02:00
|
|
|
const isDark = themeContext?.currentTheme.metadata.variant === 'dark';
|
2026-02-08 10:42:50 -03:00
|
|
|
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);
|
2026-02-01 18:29:34 +02:00
|
|
|
|
2026-02-08 10:42:50 -03:00
|
|
|
const { isMobile } = useDeviceInfo();
|
2026-02-20 18:20:33 -03:00
|
|
|
|
|
|
|
|
const diffCommentController = useInlineCommentController<SelectedLineRange>({
|
|
|
|
|
source: 'diff',
|
|
|
|
|
fileLabel: fileName || 'unknown',
|
|
|
|
|
language,
|
2026-06-13 23:55:50 +03:00
|
|
|
getCodeForRange: (range) => extractSelectedCode(original, modified, fileDiff, range),
|
2026-02-20 18:20:33 -03:00
|
|
|
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;
|
|
|
|
|
|
2026-02-13 19:05:31 +02:00
|
|
|
const selectionRef = useRef<SelectedLineRange | null>(null);
|
|
|
|
|
const editingDraftIdRef = useRef<string | null>(null);
|
2026-06-13 00:48:33 +03:00
|
|
|
const commentTextRef = useRef('');
|
2026-02-08 10:42:50 -03:00
|
|
|
// Use a ref to track if we're currently applying a selection programmatically
|
|
|
|
|
// to avoid loop with onLineSelected callback
|
2026-02-03 15:05:01 -03:00
|
|
|
const isApplyingSelectionRef = useRef(false);
|
|
|
|
|
const lastAppliedSelectionRef = useRef<SelectedLineRange | null>(null);
|
|
|
|
|
|
2026-02-13 19:05:31 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
selectionRef.current = selection;
|
|
|
|
|
}, [selection]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
editingDraftIdRef.current = editingDraftId;
|
|
|
|
|
}, [editingDraftId]);
|
|
|
|
|
|
2026-06-13 00:48:33 +03:00
|
|
|
useEffect(() => {
|
|
|
|
|
commentTextRef.current = commentText;
|
|
|
|
|
}, [commentText]);
|
|
|
|
|
|
2026-01-15 18:59:23 +02:00
|
|
|
const handleSelectionChange = useCallback((range: SelectedLineRange | null) => {
|
2026-02-03 15:05:01 -03:00
|
|
|
// Ignore callbacks while we're programmatically applying selection
|
|
|
|
|
if (isApplyingSelectionRef.current) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-13 19:05:31 +02:00
|
|
|
|
|
|
|
|
const prevSelection = selectionRef.current;
|
|
|
|
|
|
2026-06-13 00:48:33 +03:00
|
|
|
if (!range && prevSelection && commentTextRef.current.trim()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 10:42:50 -03:00
|
|
|
// Mobile tap-to-extend: if selection exists and new tap is on same side, extend range
|
2026-02-13 19:05:31 +02:00
|
|
|
if (isMobile && prevSelection && range && range.side === prevSelection.side) {
|
|
|
|
|
const start = Math.min(prevSelection.start, range.start);
|
|
|
|
|
const end = Math.max(prevSelection.end, range.end);
|
2026-02-08 10:42:50 -03:00
|
|
|
setSelection({ ...range, start, end });
|
2026-02-13 19:05:31 +02:00
|
|
|
} else {
|
|
|
|
|
setSelection(range);
|
2026-02-03 15:05:01 -03:00
|
|
|
}
|
2026-02-13 19:05:31 +02:00
|
|
|
|
2026-02-08 10:42:50 -03:00
|
|
|
// Clear editing state when selection changes user-driven
|
|
|
|
|
if (range) {
|
2026-02-13 19:05:31 +02:00
|
|
|
if (!editingDraftIdRef.current) {
|
2026-01-15 18:59:23 +02:00
|
|
|
setCommentText('');
|
|
|
|
|
}
|
2026-02-08 10:42:50 -03:00
|
|
|
}
|
2026-02-20 18:20:33 -03:00
|
|
|
}, [isMobile, setCommentText, setSelection]);
|
2026-01-15 18:59:23 +02:00
|
|
|
|
2026-02-03 15:05:01 -03:00
|
|
|
const handleCancelComment = useCallback(() => {
|
2026-02-20 18:20:33 -03:00
|
|
|
cancel();
|
|
|
|
|
}, [cancel]);
|
2026-02-08 10:42:50 -03:00
|
|
|
|
2026-02-20 18:20:33 -03:00
|
|
|
const renderAnnotation = useCallback((annotation: DiffLineAnnotation<PierreAnnotationData>) => {
|
2026-02-08 10:42:50 -03:00
|
|
|
const div = document.createElement('div');
|
2026-02-20 11:52:01 -03:00
|
|
|
div.style.position = 'relative';
|
2026-02-16 14:15:19 +02:00
|
|
|
|
2026-02-20 18:20:33 -03:00
|
|
|
const id = toPierreAnnotationId(annotation.metadata);
|
2026-02-20 11:52:01 -03:00
|
|
|
|
2026-02-08 10:42:50 -03:00
|
|
|
div.dataset.annotationId = id;
|
2026-02-20 11:52:01 -03:00
|
|
|
div.dataset.annotationSide = annotation.side;
|
|
|
|
|
div.dataset.annotationLine = String(annotation.lineNumber);
|
2026-02-08 10:42:50 -03:00
|
|
|
return div;
|
2026-02-20 11:52:01 -03:00
|
|
|
}, []);
|
2026-02-08 10:42:50 -03:00
|
|
|
|
|
|
|
|
const handleSaveComment = useCallback((textToSave: string, rangeOverride?: SelectedLineRange) => {
|
2026-02-20 18:20:33 -03:00
|
|
|
saveComment(textToSave, rangeOverride ?? selection ?? undefined);
|
|
|
|
|
}, [saveComment, selection]);
|
2026-02-08 10:42:50 -03:00
|
|
|
|
2026-02-05 21:51:03 -03:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-02-20 18:20:33 -03:00
|
|
|
}, [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';
|
2026-02-05 21:51:03 -03:00
|
|
|
}, []);
|
2026-01-15 18:59:23 +02:00
|
|
|
|
2026-02-01 18:29:34 +02:00
|
|
|
ensurePierreThemeRegistered(lightTheme);
|
|
|
|
|
ensurePierreThemeRegistered(darkTheme);
|
|
|
|
|
|
|
|
|
|
const diffThemeKey = `${lightTheme.metadata.id}:${darkTheme.metadata.id}:${isDark ? 'dark' : 'light'}`;
|
|
|
|
|
|
2026-06-13 23:55:50 +03:00
|
|
|
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]);
|
|
|
|
|
|
2026-02-01 18:29:34 +02:00
|
|
|
const diffRootRef = useRef<HTMLDivElement | null>(null);
|
2026-02-01 20:53:00 +02:00
|
|
|
const diffContainerRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
const diffInstanceRef = useRef<PierreFileDiff<unknown> | null>(null);
|
2026-02-13 19:05:31 +02:00
|
|
|
const sharedVirtualizerRef = useRef<SharedVirtualizer | null>(null);
|
2026-06-13 23:55:50 +03:00
|
|
|
const instanceVirtualizerRef = useRef<Virtualizer | null>(null);
|
|
|
|
|
const instanceWorkerPoolRef = useRef<unknown>(null);
|
|
|
|
|
const instanceVirtualHunkSeparatorsRef = useRef<FileDiffOptions<unknown>['hunkSeparators'] | undefined>(undefined);
|
|
|
|
|
const instanceFileDiffRef = useRef<FileDiffMetadata | undefined>(undefined);
|
|
|
|
|
const instanceOldFileRef = useRef<FileContents | undefined>(undefined);
|
|
|
|
|
const instanceNewFileRef = useRef<FileContents | undefined>(undefined);
|
2026-02-08 10:42:50 -03:00
|
|
|
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
|
2026-06-13 23:55:50 +03:00
|
|
|
const workerPool = useWorkerPool(isLargeContent ? 'unified' : (renderSideBySide ? 'split' : 'unified'));
|
2026-02-01 18:29:34 +02:00
|
|
|
|
|
|
|
|
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]);
|
2025-12-15 00:20:26 +02:00
|
|
|
|
2026-02-08 10:42:50 -03:00
|
|
|
|
2025-12-14 02:29:46 +02:00
|
|
|
const options = useMemo(() => ({
|
|
|
|
|
theme: {
|
2026-02-01 18:29:34 +02:00
|
|
|
dark: darkTheme.metadata.id,
|
|
|
|
|
light: lightTheme.metadata.id,
|
2025-12-14 02:29:46 +02:00
|
|
|
},
|
|
|
|
|
themeType: isDark ? ('dark' as const) : ('light' as const),
|
|
|
|
|
diffStyle: renderSideBySide ? ('split' as const) : ('unified' as const),
|
|
|
|
|
diffIndicators: 'none' as const,
|
2026-02-16 14:15:19 +02:00
|
|
|
hunkSeparators: 'line-info-basic' as const,
|
2026-02-01 20:53:00 +02:00
|
|
|
// Perf: disable intra-line diff (word-level) globally.
|
|
|
|
|
lineDiffType: 'none' as const,
|
2026-03-31 18:47:00 +03:00
|
|
|
// Perf: degrade tokenization/highlighting for large files (>500KB)
|
|
|
|
|
maxLineDiffLength: isLargeContent ? 0 : 1000,
|
|
|
|
|
maxLineLengthForHighlighting: isLargeContent ? 1 : 1000,
|
2026-06-13 23:55:50 +03:00
|
|
|
tokenizeMaxLineLength: isLargeContent ? 1 : 1000,
|
2026-02-13 19:05:31 +02:00
|
|
|
expansionLineCount: 20,
|
2025-12-14 02:29:46 +02:00
|
|
|
overflow: wrapLines ? ('wrap' as const) : ('scroll' as const),
|
|
|
|
|
disableFileHeader: true,
|
2026-01-15 18:59:23 +02:00
|
|
|
enableLineSelection: true,
|
2025-12-14 02:29:46 +02:00
|
|
|
enableHoverUtility: false,
|
2026-01-15 18:59:23 +02:00
|
|
|
onLineSelected: handleSelectionChange,
|
2025-12-14 02:29:46 +02:00
|
|
|
unsafeCSS: WEBKIT_SCROLL_FIX_CSS,
|
2026-02-08 10:42:50 -03:00
|
|
|
renderAnnotation,
|
2026-03-31 18:47:00 +03:00
|
|
|
}), [darkTheme.metadata.id, isDark, isLargeContent, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange, renderAnnotation]);
|
2026-02-08 10:42:50 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
const lineAnnotations = useMemo(() => {
|
2026-02-20 18:20:33 -03:00
|
|
|
return buildPierreLineAnnotations({
|
|
|
|
|
drafts: fileDrafts,
|
|
|
|
|
editingDraftId,
|
|
|
|
|
selection,
|
2026-02-08 10:42:50 -03:00
|
|
|
});
|
2026-02-20 18:20:33 -03:00
|
|
|
}, [editingDraftId, fileDrafts, selection]);
|
2026-02-01 18:29:34 +02:00
|
|
|
|
2026-02-13 19:05:31 +02:00
|
|
|
const lineAnnotationsRef = useRef(lineAnnotations);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
lineAnnotationsRef.current = lineAnnotations;
|
|
|
|
|
}, [lineAnnotations]);
|
|
|
|
|
|
2026-06-13 23:55:50 +03:00
|
|
|
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 = '';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-01 20:53:00 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (typeof window === 'undefined') return;
|
|
|
|
|
|
|
|
|
|
const container = diffContainerRef.current;
|
2026-06-13 23:55:50 +03:00
|
|
|
const wrapper = diffRootRef.current;
|
2026-02-01 20:53:00 +02:00
|
|
|
if (!container) return;
|
|
|
|
|
if (!workerPool) return;
|
|
|
|
|
|
2026-06-13 23:55:50 +03:00
|
|
|
const preserveDone = preserveScrollPosition(wrapper, container);
|
|
|
|
|
let sharedVirtualizer = sharedVirtualizerRef.current;
|
|
|
|
|
if (!sharedVirtualizer) {
|
|
|
|
|
sharedVirtualizer = acquireSharedVirtualizer(container);
|
|
|
|
|
sharedVirtualizerRef.current = sharedVirtualizer;
|
|
|
|
|
}
|
2026-02-13 19:05:31 +02:00
|
|
|
sharedVirtualizerRef.current = sharedVirtualizer;
|
2026-06-13 23:55:50 +03:00
|
|
|
const virtualizer = sharedVirtualizer?.virtualizer ?? null;
|
2026-02-13 19:05:31 +02:00
|
|
|
|
2026-06-13 23:55:50 +03:00
|
|
|
const oldFile: FileContents | undefined = fileDiff ? undefined : {
|
2026-02-08 10:42:50 -03:00
|
|
|
name: fileName || '',
|
2026-02-01 20:53:00 +02:00
|
|
|
contents: original,
|
|
|
|
|
lang: language as FileContents['lang'],
|
|
|
|
|
cacheKey: `old:${diffThemeKey}:${fileName}:${makeContentCacheKey(original)}`,
|
|
|
|
|
};
|
2026-06-13 23:55:50 +03:00
|
|
|
const newFile: FileContents | undefined = fileDiff ? undefined : {
|
2026-02-08 10:42:50 -03:00
|
|
|
name: fileName || '',
|
2026-02-01 20:53:00 +02:00
|
|
|
contents: modified,
|
|
|
|
|
lang: language as FileContents['lang'],
|
|
|
|
|
cacheKey: `new:${diffThemeKey}:${fileName}:${makeContentCacheKey(modified)}`,
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-13 23:55:50 +03:00
|
|
|
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 = '';
|
|
|
|
|
}
|
2026-02-01 20:53:00 +02:00
|
|
|
|
2026-06-13 23:55:50 +03:00
|
|
|
let instance = diffInstanceRef.current;
|
|
|
|
|
const forceRender = !shouldReset && currentInstance
|
|
|
|
|
? !areOptionsEqual(currentInstance.options, options)
|
|
|
|
|
: false;
|
|
|
|
|
if (!instance) {
|
|
|
|
|
instance = sharedVirtualizer
|
|
|
|
|
? new VirtualizedFileDiff(
|
|
|
|
|
options as unknown as FileDiffOptions<unknown>,
|
|
|
|
|
sharedVirtualizer.virtualizer,
|
|
|
|
|
VIRTUAL_METRICS,
|
|
|
|
|
workerPool,
|
|
|
|
|
)
|
|
|
|
|
: new PierreFileDiff(options as unknown as FileDiffOptions<unknown>, workerPool);
|
|
|
|
|
diffInstanceRef.current = instance;
|
|
|
|
|
lastAppliedSelectionRef.current = null;
|
|
|
|
|
} else {
|
|
|
|
|
instance.setOptions(options as unknown as FileDiffOptions<unknown>);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-02-16 14:15:19 +02:00
|
|
|
});
|
2026-02-08 10:42:50 -03:00
|
|
|
|
2026-02-01 20:53:00 +02:00
|
|
|
return () => {
|
2026-06-13 23:55:50 +03:00
|
|
|
cancelReady();
|
|
|
|
|
preserveDone();
|
2026-02-01 20:53:00 +02:00
|
|
|
};
|
2026-06-13 23:55:50 +03:00
|
|
|
}, [diffThemeKey, fileDiff, fileName, language, modified, options, original, workerPool]);
|
2026-02-13 19:05:31 +02:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const instance = diffInstanceRef.current;
|
|
|
|
|
if (!instance) return;
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
try {
|
|
|
|
|
instance.setLineAnnotations(lineAnnotations);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to apply diff line annotations', error);
|
|
|
|
|
try {
|
|
|
|
|
instance.setLineAnnotations([]);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignored
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 19:05:31 +02:00
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
if (diffInstanceRef.current !== instance) return;
|
|
|
|
|
try {
|
|
|
|
|
instance.rerender();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
void err;
|
|
|
|
|
}
|
|
|
|
|
forceUpdate();
|
|
|
|
|
});
|
2026-02-20 11:52:01 -03:00
|
|
|
}, [lineAnnotations]);
|
2026-02-01 20:53:00 +02:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const instance = diffInstanceRef.current;
|
|
|
|
|
if (!instance) return;
|
2026-02-03 15:05:01 -03:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 20:53:00 +02:00
|
|
|
try {
|
2026-02-03 15:05:01 -03:00
|
|
|
isApplyingSelectionRef.current = true;
|
2026-02-01 20:53:00 +02:00
|
|
|
instance.setSelectedLines(selection);
|
2026-02-03 15:05:01 -03:00
|
|
|
lastAppliedSelectionRef.current = selection;
|
2026-02-01 20:53:00 +02:00
|
|
|
} catch {
|
|
|
|
|
// ignore
|
2026-02-03 15:05:01 -03:00
|
|
|
} finally {
|
|
|
|
|
isApplyingSelectionRef.current = false;
|
2026-02-01 20:53:00 +02:00
|
|
|
}
|
|
|
|
|
}, [selection]);
|
|
|
|
|
|
2026-02-13 19:05:31 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
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;
|
|
|
|
|
|
2026-02-20 18:20:33 -03:00
|
|
|
const side = resolveClickedSide(numberCell);
|
2026-02-13 19:05:31 +02:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
};
|
2026-02-20 18:20:33 -03:00
|
|
|
}, [diffThemeKey, fileName, handleSelectionChange, resolveClickedSide]);
|
2026-02-13 19:05:31 +02:00
|
|
|
|
2026-02-08 10:42:50 -03:00
|
|
|
// 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');
|
2026-02-20 18:20:33 -03:00
|
|
|
if (!(diffsContainer instanceof HTMLElement)) return;
|
2026-02-08 10:42:50 -03:00
|
|
|
|
2026-02-20 11:52:01 -03:00
|
|
|
const shadowRoot = diffsContainer.shadowRoot;
|
2026-02-08 10:42:50 -03:00
|
|
|
|
2026-02-20 11:52:01 -03:00
|
|
|
observer = new MutationObserver(() => {
|
|
|
|
|
if (rafId) cancelAnimationFrame(rafId);
|
|
|
|
|
rafId = requestAnimationFrame(() => {
|
|
|
|
|
forceUpdate();
|
|
|
|
|
rafId = null;
|
|
|
|
|
});
|
2026-02-08 10:42:50 -03:00
|
|
|
});
|
|
|
|
|
|
2026-02-20 18:20:33 -03:00
|
|
|
observer.observe(diffsContainer, { childList: true, subtree: true });
|
|
|
|
|
if (shadowRoot) {
|
|
|
|
|
observer.observe(shadowRoot, { childList: true, subtree: true });
|
|
|
|
|
}
|
2026-02-08 10:42:50 -03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const timeoutId = setTimeout(setupObserver, 100);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
|
if (rafId) cancelAnimationFrame(rafId);
|
|
|
|
|
observer?.disconnect();
|
|
|
|
|
};
|
2026-02-20 11:52:01 -03:00
|
|
|
}, [diffThemeKey, fileName]);
|
2026-02-08 10:42:50 -03:00
|
|
|
|
2025-12-14 02:29:46 +02:00
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-02-01 18:29:34 +02:00
|
|
|
|
2026-02-20 18:20:33 -03:00
|
|
|
const commentOverlays = (
|
|
|
|
|
<PierreDiffCommentOverlays
|
|
|
|
|
diffRootRef={diffRootRef}
|
|
|
|
|
drafts={fileDrafts}
|
|
|
|
|
selection={selection}
|
|
|
|
|
editingDraftId={editingDraftId}
|
|
|
|
|
commentText={commentText}
|
2026-06-13 00:48:33 +03:00
|
|
|
onTextChange={setCommentText}
|
2026-02-20 18:20:33 -03:00
|
|
|
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}
|
|
|
|
|
/>
|
2026-02-16 14:15:19 +02:00
|
|
|
);
|
2026-01-15 18:59:23 +02:00
|
|
|
|
|
|
|
|
if (layout === 'fill') {
|
|
|
|
|
return (
|
2026-02-13 19:05:31 +02:00
|
|
|
<div className={cn("flex flex-col relative", "size-full")} data-diff-virtual-root>
|
2026-01-15 18:59:23 +02:00
|
|
|
<div className="flex-1 relative min-h-0">
|
|
|
|
|
<ScrollableOverlay
|
|
|
|
|
outerClassName="pierre-diff-wrapper size-full"
|
|
|
|
|
disableHorizontal={false}
|
|
|
|
|
fillContainer={true}
|
2026-02-13 19:05:31 +02:00
|
|
|
data-diff-virtual-content
|
2026-01-15 18:59:23 +02:00
|
|
|
>
|
2026-02-03 15:05:01 -03:00
|
|
|
<div ref={diffRootRef} className="size-full relative">
|
2026-02-01 20:53:00 +02:00
|
|
|
<div ref={diffContainerRef} className="size-full" />
|
2026-02-01 18:29:34 +02:00
|
|
|
</div>
|
2026-01-15 18:59:23 +02:00
|
|
|
</ScrollableOverlay>
|
2026-02-20 18:20:33 -03:00
|
|
|
{commentOverlays}
|
2026-01-15 18:59:23 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 10:42:50 -03:00
|
|
|
// Fallback for 'inline' layout
|
2025-12-14 02:29:46 +02:00
|
|
|
return (
|
2026-01-15 18:59:23 +02:00
|
|
|
<div className={cn("relative", "w-full")}>
|
2026-02-03 15:05:01 -03:00
|
|
|
<div ref={diffRootRef} className="pierre-diff-wrapper w-full overflow-x-auto overflow-y-visible relative">
|
2026-02-01 20:53:00 +02:00
|
|
|
<div ref={diffContainerRef} className="w-full" />
|
2026-01-25 18:52:58 +02:00
|
|
|
</div>
|
2026-02-20 18:20:33 -03:00
|
|
|
{commentOverlays}
|
2026-01-15 18:59:23 +02:00
|
|
|
</div>
|
2025-12-14 02:29:46 +02:00
|
|
|
);
|
|
|
|
|
};
|