2026-01-15 18:59:23 +02:00
|
|
|
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
|
|
|
|
|
import { createPortal } from 'react-dom';
|
2026-02-08 10:42:50 -03:00
|
|
|
import { FileDiff as PierreFileDiff, type FileContents, type FileDiffOptions, type SelectedLineRange, type DiffLineAnnotation, type AnnotationSide } from '@pierre/diffs';
|
|
|
|
|
import { InlineCommentCard, InlineCommentInput } 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-19 02:50:40 +02:00
|
|
|
import { toast } from '@/components/ui';
|
2026-01-15 18:59:23 +02:00
|
|
|
import { useSessionStore } from '@/stores/useSessionStore';
|
|
|
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
|
|
|
import { useDeviceInfo } from '@/lib/device';
|
2026-02-08 10:42:50 -03:00
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
import { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
2026-01-15 18:59:23 +02:00
|
|
|
|
|
|
|
|
|
2025-12-14 02:29:46 +02:00
|
|
|
interface PierreDiffViewerProps {
|
|
|
|
|
original: string;
|
|
|
|
|
modified: string;
|
|
|
|
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CSS injected into Pierre's Shadow DOM for WebKit scroll optimization
|
|
|
|
|
// Note: avoid will-change and contain:paint as they break resize behavior
|
|
|
|
|
const WEBKIT_SCROLL_FIX_CSS = `
|
2025-12-16 13:26:03 +02:00
|
|
|
:host {
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
font-size: var(--text-code);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-14 02:29:46 +02:00
|
|
|
:host, pre, [data-diffs], [data-code] {
|
|
|
|
|
transform: translateZ(0);
|
|
|
|
|
-webkit-transform: translateZ(0);
|
|
|
|
|
-webkit-backface-visibility: hidden;
|
|
|
|
|
backface-visibility: hidden;
|
|
|
|
|
}
|
2025-12-16 13:26:03 +02:00
|
|
|
|
|
|
|
|
pre, [data-code] {
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
font-size: var(--text-code);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-14 02:29:46 +02:00
|
|
|
[data-code] {
|
|
|
|
|
-webkit-overflow-scrolling: touch;
|
|
|
|
|
}
|
2026-02-01 18:29:34 +02:00
|
|
|
|
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;
|
|
|
|
|
}
|
2025-12-15 13:56:01 +02:00
|
|
|
/* Reduce hunk separator height */
|
2026-02-01 18:29:34 +02:00
|
|
|
// [data-separator-content] {
|
|
|
|
|
// height: 24px !important;
|
|
|
|
|
// }
|
|
|
|
|
// [data-expand-button] {
|
|
|
|
|
// height: 24px !important;
|
|
|
|
|
// width: 24px !important;
|
|
|
|
|
// }
|
|
|
|
|
// [data-separator-multi-button] {
|
|
|
|
|
// row-gap: 0 !important;
|
|
|
|
|
// }
|
|
|
|
|
// [data-expand-up] {
|
|
|
|
|
// height: 12px !important;
|
|
|
|
|
// min-height: 12px !important;
|
|
|
|
|
// max-height: 12px !important;
|
|
|
|
|
// margin: 0 !important;
|
|
|
|
|
// margin-top: 3px !important;
|
|
|
|
|
// padding: 0 !important;
|
|
|
|
|
// border-radius: 4px 4px 0 0 !important;
|
|
|
|
|
// }
|
|
|
|
|
// [data-expand-down] {
|
|
|
|
|
// height: 12px !important;
|
|
|
|
|
// min-height: 12px !important;
|
|
|
|
|
// max-height: 12px !important;
|
|
|
|
|
// margin: 0 !important;
|
|
|
|
|
// margin-top: -3px !important;
|
|
|
|
|
// padding: 0 !important;
|
|
|
|
|
// border-radius: 0 0 4px 4px !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-01-15 18:59:23 +02:00
|
|
|
const extractSelectedCode = (original: string, modified: string, range: SelectedLineRange): string => {
|
|
|
|
|
// Default to modified if side is ambiguous, as users mostly comment on new code
|
|
|
|
|
const isOriginal = range.side === 'deletions';
|
|
|
|
|
const content = isOriginal ? original : modified;
|
|
|
|
|
const lines = content.split('\n');
|
2026-02-01 18:29:34 +02:00
|
|
|
|
2026-01-15 18:59:23 +02:00
|
|
|
// Ensure bounds
|
|
|
|
|
const startLine = Math.max(1, range.start);
|
|
|
|
|
const endLine = Math.min(lines.length, range.end);
|
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-02-08 10:42:50 -03:00
|
|
|
type AnnotationData =
|
|
|
|
|
| { type: 'saved' | 'edit'; draft: InlineCommentDraft }
|
|
|
|
|
| { type: 'new'; selection: SelectedLineRange };
|
|
|
|
|
|
2025-12-14 02:29:46 +02:00
|
|
|
export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|
|
|
|
original,
|
|
|
|
|
modified,
|
|
|
|
|
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();
|
|
|
|
|
|
|
|
|
|
const isDark = themeContext?.themeMode === '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);
|
2026-02-01 18:29:34 +02:00
|
|
|
|
2026-02-08 10:42:50 -03:00
|
|
|
useUIStore();
|
|
|
|
|
const { isMobile } = useDeviceInfo();
|
|
|
|
|
|
|
|
|
|
const addDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
|
|
|
|
const updateDraft = useInlineCommentDraftStore((state) => state.updateDraft);
|
|
|
|
|
const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
|
|
|
|
const allDrafts = useInlineCommentDraftStore((state) => state.drafts);
|
|
|
|
|
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
|
|
|
|
const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open);
|
2026-02-01 18:29:34 +02:00
|
|
|
|
2026-02-08 10:42:50 -03:00
|
|
|
const getSessionKey = useCallback(() => {
|
|
|
|
|
return currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
|
|
|
|
}, [currentSessionId, newSessionDraftOpen]);
|
2026-02-01 18:29:34 +02:00
|
|
|
|
2026-01-15 18:59:23 +02:00
|
|
|
const [selection, setSelection] = useState<SelectedLineRange | null>(null);
|
|
|
|
|
const [commentText, setCommentText] = useState('');
|
2026-02-05 21:51:03 -03:00
|
|
|
const [editingDraftId, setEditingDraftId] = useState<string | null>(null);
|
2026-02-01 18:29:34 +02:00
|
|
|
|
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-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-08 10:42:50 -03:00
|
|
|
|
|
|
|
|
// Mobile tap-to-extend: if selection exists and new tap is on same side, extend range
|
|
|
|
|
if (isMobile && selection && range && range.side === selection.side) {
|
|
|
|
|
const start = Math.min(selection.start, range.start);
|
|
|
|
|
const end = Math.max(selection.end, range.end);
|
|
|
|
|
setSelection({ ...range, start, end });
|
2026-02-03 15:05:01 -03:00
|
|
|
return;
|
|
|
|
|
}
|
2026-02-08 10:42:50 -03:00
|
|
|
|
2026-01-15 18:59:23 +02:00
|
|
|
setSelection(range);
|
2026-02-08 10:42:50 -03:00
|
|
|
|
|
|
|
|
// Clear editing state when selection changes user-driven
|
|
|
|
|
if (range) {
|
|
|
|
|
// Don't clear if we're just updating the selection for the same draft?
|
|
|
|
|
// For now, simple behavior: new selection = new comment flow
|
|
|
|
|
if (!editingDraftId) {
|
2026-01-15 18:59:23 +02:00
|
|
|
setCommentText('');
|
|
|
|
|
}
|
2026-02-08 10:42:50 -03:00
|
|
|
}
|
|
|
|
|
}, [editingDraftId, isMobile, selection]);
|
2026-01-15 18:59:23 +02:00
|
|
|
|
2026-02-03 15:05:01 -03:00
|
|
|
const handleCancelComment = useCallback(() => {
|
|
|
|
|
setCommentText('');
|
|
|
|
|
setSelection(null);
|
2026-02-05 21:51:03 -03:00
|
|
|
setEditingDraftId(null);
|
2026-02-03 15:05:01 -03:00
|
|
|
}, []);
|
2026-01-15 20:19:06 +02:00
|
|
|
|
2026-02-08 10:42:50 -03:00
|
|
|
// Helper to generate consistent annotation IDs
|
|
|
|
|
const getAnnotationId = useCallback((meta: AnnotationData): string => {
|
|
|
|
|
if (meta.type === 'saved' || meta.type === 'edit') {
|
|
|
|
|
return `draft-${meta.draft.id}`;
|
|
|
|
|
} else if (meta.type === 'new') {
|
|
|
|
|
return 'new-comment-input';
|
|
|
|
|
}
|
|
|
|
|
return '';
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Robust target resolver that checks shadow root, light DOM, and container
|
|
|
|
|
const resolveAnnotationTarget = useCallback((id: string): HTMLElement | null => {
|
|
|
|
|
if (!id || !diffContainerRef.current) return null;
|
|
|
|
|
|
|
|
|
|
const diffsContainer = diffContainerRef.current.querySelector('diffs-container');
|
|
|
|
|
if (!diffsContainer) return null;
|
|
|
|
|
|
|
|
|
|
// Try shadow root first
|
|
|
|
|
const shadowTarget = diffsContainer.shadowRoot?.querySelector(`[data-annotation-id="${id}"]`);
|
|
|
|
|
if (shadowTarget) return shadowTarget as HTMLElement;
|
|
|
|
|
|
|
|
|
|
// Try light DOM (slotted content)
|
|
|
|
|
const lightTarget = diffsContainer.querySelector(`[data-annotation-id="${id}"]`);
|
|
|
|
|
if (lightTarget) return lightTarget as HTMLElement;
|
|
|
|
|
|
|
|
|
|
// Try container directly
|
|
|
|
|
const containerTarget = diffContainerRef.current.querySelector(`[data-annotation-id="${id}"]`);
|
|
|
|
|
if (containerTarget) return containerTarget as HTMLElement;
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}, []);
|
2026-02-05 21:51:03 -03:00
|
|
|
|
2026-02-08 10:42:50 -03:00
|
|
|
const renderAnnotation = useCallback((annotation: DiffLineAnnotation<AnnotationData>) => {
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
// Ensure full width and proper spacing
|
|
|
|
|
div.className = 'w-full my-2';
|
|
|
|
|
|
|
|
|
|
const meta = (annotation as DiffLineAnnotation<AnnotationData>).metadata;
|
|
|
|
|
const id = getAnnotationId(meta);
|
|
|
|
|
|
|
|
|
|
div.dataset.annotationId = id;
|
|
|
|
|
return div;
|
|
|
|
|
}, [getAnnotationId]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const handleSaveComment = useCallback((textToSave: string, rangeOverride?: SelectedLineRange) => {
|
|
|
|
|
// Use provided range override or fall back to current selection
|
|
|
|
|
const targetRange = rangeOverride ?? selection;
|
|
|
|
|
if (!targetRange || !textToSave.trim()) return;
|
2026-01-15 20:19:06 +02:00
|
|
|
|
2026-02-03 15:05:01 -03:00
|
|
|
const sessionKey = getSessionKey();
|
|
|
|
|
if (!sessionKey) {
|
|
|
|
|
toast.error('Select a session to save comment');
|
2026-01-15 18:59:23 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-01-15 20:19:06 +02:00
|
|
|
|
2026-02-08 10:42:50 -03:00
|
|
|
// Pierre selection range: { start, end, side }
|
|
|
|
|
// Store needs { startLine, endLine, side: 'original'|'modified' }
|
|
|
|
|
// Pierre side: 'additions' (right) | 'deletions' (left)
|
|
|
|
|
const storeSide = targetRange.side === 'deletions' ? 'original' : 'modified';
|
|
|
|
|
|
|
|
|
|
// Use deterministic code extraction instead of instance.getSelectedText()
|
|
|
|
|
const selectedText = extractSelectedCode(original, modified, targetRange);
|
2026-02-01 18:29:34 +02:00
|
|
|
|
2026-02-05 21:51:03 -03:00
|
|
|
if (editingDraftId) {
|
|
|
|
|
updateDraft(sessionKey, editingDraftId, {
|
2026-02-09 11:52:41 -03:00
|
|
|
fileLabel: fileName || 'unknown',
|
2026-02-08 10:42:50 -03:00
|
|
|
startLine: targetRange.start,
|
|
|
|
|
endLine: targetRange.end,
|
|
|
|
|
side: storeSide,
|
|
|
|
|
code: selectedText,
|
|
|
|
|
language: language,
|
|
|
|
|
text: textToSave.trim(),
|
2026-02-05 21:51:03 -03:00
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
addDraft({
|
|
|
|
|
sessionKey,
|
|
|
|
|
source: 'diff',
|
2026-02-09 11:52:41 -03:00
|
|
|
fileLabel: fileName || 'unknown',
|
2026-02-08 10:42:50 -03:00
|
|
|
startLine: targetRange.start,
|
|
|
|
|
endLine: targetRange.end,
|
|
|
|
|
side: storeSide,
|
|
|
|
|
code: selectedText,
|
|
|
|
|
language: language,
|
|
|
|
|
text: textToSave.trim(),
|
2026-02-05 21:51:03 -03:00
|
|
|
});
|
|
|
|
|
}
|
2026-02-01 18:29:34 +02:00
|
|
|
|
2026-01-15 18:59:23 +02:00
|
|
|
setCommentText('');
|
|
|
|
|
setSelection(null);
|
2026-02-05 21:51:03 -03:00
|
|
|
setEditingDraftId(null);
|
2026-02-08 10:42:50 -03:00
|
|
|
}, [selection, fileName, language, original, modified, addDraft, updateDraft, getSessionKey, editingDraftId]);
|
|
|
|
|
|
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-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'}`;
|
|
|
|
|
|
|
|
|
|
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-08 10:42:50 -03:00
|
|
|
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
|
2026-02-01 20:53:00 +02:00
|
|
|
const workerPool = useWorkerPool();
|
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,
|
|
|
|
|
hunkSeparators: 'line-info' as const,
|
2026-02-01 20:53:00 +02:00
|
|
|
// Perf: disable intra-line diff (word-level) globally.
|
|
|
|
|
lineDiffType: 'none' as const,
|
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,
|
|
|
|
|
}), [darkTheme.metadata.id, isDark, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange, renderAnnotation]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const lineAnnotations = useMemo(() => {
|
|
|
|
|
const sessionKey = getSessionKey();
|
|
|
|
|
if (!sessionKey) return [];
|
|
|
|
|
|
|
|
|
|
const sessionDrafts = allDrafts[sessionKey] ?? [];
|
|
|
|
|
// Match file label logic - use basename
|
2026-02-09 11:52:41 -03:00
|
|
|
const fileLabel = fileName || 'unknown';
|
2026-02-08 10:42:50 -03:00
|
|
|
const fileDrafts = sessionDrafts.filter((d) => d.source === 'diff' && d.fileLabel === fileLabel);
|
|
|
|
|
|
|
|
|
|
const anns: DiffLineAnnotation<AnnotationData>[] = [];
|
|
|
|
|
|
|
|
|
|
fileDrafts.forEach((d) => {
|
|
|
|
|
// Force cast to AnnotationSide to satisfy compiler
|
|
|
|
|
const side = (d.side === 'original' ? 'deletions' : 'additions') as AnnotationSide;
|
|
|
|
|
if (d.id === editingDraftId) {
|
|
|
|
|
// Always show edit input (even on mobile)
|
|
|
|
|
anns.push({
|
|
|
|
|
lineNumber: d.endLine,
|
|
|
|
|
side: side,
|
|
|
|
|
metadata: { type: 'edit', draft: d },
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
// Show saved cards on all devices
|
|
|
|
|
anns.push({
|
|
|
|
|
lineNumber: d.endLine,
|
|
|
|
|
side: side,
|
|
|
|
|
metadata: { type: 'saved', draft: d },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (selection && !editingDraftId) {
|
|
|
|
|
anns.push({
|
|
|
|
|
lineNumber: selection.end,
|
|
|
|
|
side: (selection.side ?? 'additions') as AnnotationSide,
|
|
|
|
|
metadata: { type: 'new', selection },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return anns;
|
|
|
|
|
}, [allDrafts, getSessionKey, fileName, editingDraftId, selection]);
|
2026-02-01 18:29:34 +02:00
|
|
|
|
2026-02-01 20:53:00 +02:00
|
|
|
// Imperative render (like upstream OpenCode): avoids `parseDiffFromFile` on main thread.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (typeof window === 'undefined') return;
|
|
|
|
|
|
|
|
|
|
const container = diffContainerRef.current;
|
|
|
|
|
if (!container) return;
|
|
|
|
|
if (!workerPool) return;
|
|
|
|
|
|
|
|
|
|
// Dispose previous instance
|
|
|
|
|
diffInstanceRef.current?.cleanUp();
|
|
|
|
|
diffInstanceRef.current = null;
|
|
|
|
|
container.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
const instance = new PierreFileDiff(options as unknown as FileDiffOptions<unknown>, workerPool);
|
|
|
|
|
diffInstanceRef.current = instance;
|
2026-02-03 15:05:01 -03:00
|
|
|
lastAppliedSelectionRef.current = null;
|
2026-02-01 20:53:00 +02:00
|
|
|
|
|
|
|
|
const oldFile: FileContents = {
|
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)}`,
|
|
|
|
|
};
|
|
|
|
|
const newFile: FileContents = {
|
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)}`,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
instance.render({
|
|
|
|
|
oldFile,
|
|
|
|
|
newFile,
|
2026-02-08 10:42:50 -03:00
|
|
|
lineAnnotations,
|
2026-02-01 20:53:00 +02:00
|
|
|
containerWrapper: container,
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-08 10:42:50 -03:00
|
|
|
// Force update to render portals into new DOM elements created by Pierre
|
|
|
|
|
requestAnimationFrame(() => forceUpdate());
|
|
|
|
|
|
2026-02-01 20:53:00 +02:00
|
|
|
return () => {
|
|
|
|
|
instance.cleanUp();
|
|
|
|
|
if (diffInstanceRef.current === instance) {
|
|
|
|
|
diffInstanceRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
container.innerHTML = '';
|
|
|
|
|
};
|
2026-02-08 10:42:50 -03:00
|
|
|
}, [diffThemeKey, fileName, language, modified, options, original, workerPool, 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-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');
|
|
|
|
|
if (!diffsContainer) return;
|
|
|
|
|
|
|
|
|
|
// Watch for annotation nodes being added/removed
|
|
|
|
|
observer = new MutationObserver((mutations) => {
|
|
|
|
|
const hasAnnotationChanges = mutations.some(m =>
|
|
|
|
|
Array.from(m.addedNodes).some(n =>
|
|
|
|
|
n instanceof HTMLElement && n.hasAttribute('data-annotation-id')
|
|
|
|
|
) ||
|
|
|
|
|
Array.from(m.removedNodes).some(n =>
|
|
|
|
|
n instanceof HTMLElement && n.hasAttribute('data-annotation-id')
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (hasAnnotationChanges) {
|
|
|
|
|
// Debounce with RAF to batch multiple mutations
|
|
|
|
|
if (rafId) cancelAnimationFrame(rafId);
|
|
|
|
|
rafId = requestAnimationFrame(() => {
|
|
|
|
|
forceUpdate();
|
|
|
|
|
rafId = null;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Observe both shadow root and light DOM
|
|
|
|
|
if (diffsContainer.shadowRoot) {
|
|
|
|
|
observer.observe(diffsContainer.shadowRoot, { childList: true, subtree: true });
|
|
|
|
|
}
|
|
|
|
|
observer.observe(diffsContainer, { childList: true, subtree: true });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Delay setup to allow Pierre to initialize
|
|
|
|
|
const timeoutId = setTimeout(setupObserver, 100);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
|
if (rafId) cancelAnimationFrame(rafId);
|
|
|
|
|
observer?.disconnect();
|
|
|
|
|
};
|
|
|
|
|
}, [diffThemeKey, fileName]); // Re-setup when diff changes
|
|
|
|
|
|
2025-12-14 02:29:46 +02:00
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-02-01 18:29:34 +02:00
|
|
|
|
2026-02-08 10:42:50 -03:00
|
|
|
// Render portals for inline comments with robust target resolution
|
|
|
|
|
const portals = lineAnnotations.map((ann) => {
|
|
|
|
|
const meta = (ann as DiffLineAnnotation<AnnotationData>).metadata;
|
|
|
|
|
const id = getAnnotationId(meta);
|
|
|
|
|
|
|
|
|
|
// Use robust resolver that checks shadow, light DOM, and container
|
|
|
|
|
const target = resolveAnnotationTarget(id);
|
|
|
|
|
|
|
|
|
|
// If target not found, skip rendering (will retry on next update cycle)
|
|
|
|
|
if (!target) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-02-03 15:05:01 -03:00
|
|
|
|
2026-02-08 10:42:50 -03:00
|
|
|
if (meta.type === 'saved') {
|
|
|
|
|
return createPortal(
|
|
|
|
|
<InlineCommentCard
|
|
|
|
|
key={id}
|
|
|
|
|
draft={meta.draft}
|
|
|
|
|
onEdit={() => {
|
|
|
|
|
const side = meta.draft.side === 'original' ? 'deletions' : 'additions';
|
|
|
|
|
applySelection({
|
|
|
|
|
start: meta.draft.startLine,
|
|
|
|
|
end: meta.draft.endLine,
|
|
|
|
|
side,
|
|
|
|
|
});
|
|
|
|
|
setCommentText(meta.draft.text);
|
|
|
|
|
setEditingDraftId(meta.draft.id);
|
|
|
|
|
}}
|
|
|
|
|
onDelete={() => removeDraft(meta.draft.sessionKey, meta.draft.id)}
|
|
|
|
|
/>,
|
|
|
|
|
target,
|
|
|
|
|
id
|
|
|
|
|
);
|
|
|
|
|
} else if (meta.type === 'edit') {
|
|
|
|
|
return createPortal(
|
|
|
|
|
<InlineCommentInput
|
|
|
|
|
key={id}
|
|
|
|
|
initialText={commentText}
|
|
|
|
|
fileLabel={(fileName?.split('/').pop()) ?? ''}
|
|
|
|
|
lineRange={{
|
|
|
|
|
start: meta.draft.startLine,
|
|
|
|
|
end: meta.draft.endLine,
|
|
|
|
|
side: meta.draft.side === 'original' ? 'deletions' : 'additions'
|
|
|
|
|
}}
|
|
|
|
|
isEditing={true}
|
|
|
|
|
onSave={handleSaveComment}
|
|
|
|
|
onCancel={handleCancelComment}
|
|
|
|
|
/>,
|
|
|
|
|
target,
|
|
|
|
|
id
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
return createPortal(
|
|
|
|
|
<InlineCommentInput
|
|
|
|
|
key={id}
|
|
|
|
|
initialText={commentText}
|
|
|
|
|
fileLabel={(fileName?.split('/').pop()) ?? ''}
|
|
|
|
|
lineRange={selection || undefined}
|
|
|
|
|
isEditing={false}
|
|
|
|
|
onSave={handleSaveComment}
|
|
|
|
|
onCancel={handleCancelComment}
|
|
|
|
|
/>,
|
|
|
|
|
target,
|
|
|
|
|
id
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-01-15 18:59:23 +02:00
|
|
|
|
|
|
|
|
if (layout === 'fill') {
|
|
|
|
|
return (
|
2026-02-08 10:42:50 -03:00
|
|
|
<div className={cn("flex flex-col relative", "size-full")}>
|
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-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-08 10:42:50 -03:00
|
|
|
{portals}
|
2026-02-01 18:29:34 +02:00
|
|
|
</div>
|
2026-01-15 18:59:23 +02:00
|
|
|
</ScrollableOverlay>
|
|
|
|
|
</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-02-08 10:42:50 -03:00
|
|
|
{portals}
|
2026-01-25 18:52:58 +02:00
|
|
|
</div>
|
2026-01-15 18:59:23 +02:00
|
|
|
</div>
|
2025-12-14 02:29:46 +02:00
|
|
|
);
|
|
|
|
|
};
|
2026-02-08 10:42:50 -03:00
|
|
|
|
|
|
|
|
|