import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { FileDiff } from '@pierre/diffs/react'; import { parseDiffFromFile, type FileContents, type FileDiffMetadata, type SelectedLineRange } from '@pierre/diffs'; import { RiSendPlane2Line } from '@remixicon/react'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ensurePierreThemeRegistered, getResolvedShikiTheme } from '@/lib/shiki/appThemeRegistry'; import { getDefaultTheme } from '@/lib/theme/themes'; import { toast } from '@/components/ui'; import { Textarea } from '@/components/ui/textarea'; import { useSessionStore } from '@/stores/useSessionStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useContextStore } from '@/stores/contextStore'; import { useUIStore } from '@/stores/useUIStore'; import { useDeviceInfo } from '@/lib/device'; import { cn, getModifierLabel } from '@/lib/utils'; interface PierreDiffViewerProps { original: string; modified: string; language: string; fileName?: string; renderSideBySide: boolean; wrapLines?: boolean; layout?: 'fill' | 'inline'; } // 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 = ` :host { font-family: var(--font-mono); font-size: var(--text-code); } :host, pre, [data-diffs], [data-code] { transform: translateZ(0); -webkit-transform: translateZ(0); -webkit-backface-visibility: hidden; backface-visibility: hidden; } pre, [data-code] { font-family: var(--font-mono); font-size: var(--text-code); } [data-code] { -webkit-overflow-scrolling: touch; } /* 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; } /* Reduce hunk separator height */ // [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; // } `; // Fast cache key - use length + samples instead of full hash function getCacheKey(fileName: string, original: string, modified: string, themeKey: string): string { // Sample a few characters instead of hashing entire content const sampleOriginal = original.length > 100 ? `${original.slice(0, 50)}${original.slice(-50)}` : original; const sampleModified = modified.length > 100 ? `${modified.slice(0, 50)}${modified.slice(-50)}` : modified; return `${themeKey}::${fileName}:${original.length}:${modified.length}:${sampleOriginal.length}:${sampleModified.length}`; } 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'); // Ensure bounds const startLine = Math.max(1, range.start); const endLine = Math.min(lines.length, range.end); if (startLine > endLine) return ''; return lines.slice(startLine - 1, endLine).join('\n'); }; export const PierreDiffViewer: React.FC = ({ original, modified, language, fileName = 'file', renderSideBySide, wrapLines = false, layout = 'fill', }) => { const { isMobile } = useDeviceInfo(); const { inputBarOffset, isKeyboardOpen } = useUIStore(); const themeSystem = useOptionalThemeSystem(); const isDark = themeSystem?.currentTheme?.metadata?.variant === 'dark'; const fallbackLight = getDefaultTheme(false); const fallbackDark = getDefaultTheme(true); const lightThemeId = themeSystem?.lightThemeId ?? fallbackLight.metadata.id; const darkThemeId = themeSystem?.darkThemeId ?? fallbackDark.metadata.id; const lightTheme = themeSystem?.availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? fallbackLight; const darkTheme = themeSystem?.availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? fallbackDark; const setActiveMainTab = useUIStore(state => state.setActiveMainTab); const [selection, setSelection] = useState(null); const [commentText, setCommentText] = useState(''); const commentContainerRef = useRef(null); // Calculate initial center and width synchronously to avoid flicker const getMainContentMetrics = useCallback(() => { if (isMobile) return { center: '50%', width: '100vw' }; const mainContent = document.querySelector('main.flex-1'); if (mainContent) { const rect = mainContent.getBoundingClientRect(); return { center: `${rect.left + rect.width / 2}px`, width: `${rect.width}px` }; } return { center: '50%', width: '100vw' }; }, [isMobile]); const [mainContentMetrics, setMainContentMetrics] = useState(getMainContentMetrics); const mainContentCenter = mainContentMetrics.center; const mainContentWidth = mainContentMetrics.width; const sendMessage = useSessionStore(state => state.sendMessage); const currentSessionId = useSessionStore(state => state.currentSessionId); const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore(); const getSessionAgentSelection = useContextStore(state => state.getSessionAgentSelection); const getAgentModelForSession = useContextStore(state => state.getAgentModelForSession); const getAgentModelVariantForSession = useContextStore(state => state.getAgentModelVariantForSession); // Update main content metrics on resize useEffect(() => { if (isMobile) return; const updateMetrics = () => { setMainContentMetrics(getMainContentMetrics()); }; window.addEventListener('resize', updateMetrics); return () => window.removeEventListener('resize', updateMetrics); }, [isMobile, getMainContentMetrics]); const handleSelectionChange = useCallback((range: SelectedLineRange | null) => { // On mobile: implement "tap to extend" behavior // If user taps a new single line while we have an existing selection, extend the range if (isMobile && range && selection && range.start === range.end) { const tappedLine = range.start; const existingStart = selection.start; const existingEnd = selection.end; // Extend the selection to include the tapped line const newStart = Math.min(existingStart, existingEnd, tappedLine); const newEnd = Math.max(existingStart, existingEnd, tappedLine); // Only extend if tapping outside current selection if (tappedLine < existingStart || tappedLine > existingEnd) { setSelection({ ...range, start: newStart, end: newEnd, }); return; } } setSelection(range); if (!range) { setCommentText(''); } }, [isMobile, selection]); // Dismiss selection when clicking outside line numbers (desktop behavior) useEffect(() => { if (!selection) return; const handleClickOutside = (e: MouseEvent) => { const target = e.target as HTMLElement; // Check if click is inside the comment UI portal if (commentContainerRef.current?.contains(target)) return; // Check if click is inside toast (sonner) if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return; // Check if click is on a line number (inside shadow DOM) const path = e.composedPath(); const isLineNumber = path.some((el) => { if (el instanceof HTMLElement) { return el.hasAttribute('data-line-number') || el.closest?.('[data-line-number]'); } return false; }); if (!isLineNumber) { setSelection(null); setCommentText(''); } }; // Use timeout to avoid immediate dismissal from the same click that selected const timeoutId = setTimeout(() => { document.addEventListener('click', handleClickOutside); }, 100); return () => { clearTimeout(timeoutId); document.removeEventListener('click', handleClickOutside); }; }, [selection]); const handleSendComment = useCallback(async () => { if (!selection || !commentText.trim()) return; if (!currentSessionId) { toast.error('Select a session to send comment'); return; } // Get session-specific agent/model/variant with fallback to config values const sessionAgent = getSessionAgentSelection(currentSessionId) || currentAgentName; const sessionModel = sessionAgent ? getAgentModelForSession(currentSessionId, sessionAgent) : null; const effectiveProviderId = sessionModel?.providerId || currentProviderId; const effectiveModelId = sessionModel?.modelId || currentModelId; if (!effectiveProviderId || !effectiveModelId) { toast.error('Select a model to send comment'); return; } const effectiveVariant = sessionAgent && effectiveProviderId && effectiveModelId ? getAgentModelVariantForSession(currentSessionId, sessionAgent, effectiveProviderId, effectiveModelId) ?? currentVariant : currentVariant; const code = extractSelectedCode(original, modified, selection); const startLine = selection.start; const endLine = selection.end; const side = selection.side === 'deletions' ? 'original' : 'modified'; const message = `Comment on \`${fileName}\` lines ${startLine}-${endLine} (${side}):\n\`\`\`${language}\n${code}\n\`\`\`\n\n${commentText}`; // Clear state and switch tab immediately for responsive UX setCommentText(''); setSelection(null); setActiveMainTab('chat'); void sendMessage( message, effectiveProviderId, effectiveModelId, sessionAgent, undefined, undefined, undefined, effectiveVariant ).catch((e) => { console.error('Failed to send comment', e); }); }, [selection, commentText, original, modified, fileName, language, sendMessage, currentSessionId, currentProviderId, currentModelId, currentAgentName, currentVariant, setActiveMainTab, getSessionAgentSelection, getAgentModelForSession, getAgentModelVariantForSession]); ensurePierreThemeRegistered(lightTheme); ensurePierreThemeRegistered(darkTheme); const diffThemeKey = `${lightTheme.metadata.id}:${darkTheme.metadata.id}:${isDark ? 'dark' : 'light'}`; const diffRootRef = useRef(null); 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 | 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
 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]);

  // Cache the last computed diff to avoid recomputing on every render
  const diffCacheRef = useRef<{
    key: string;
    fileDiff: FileDiffMetadata;
  } | null>(null);

  // Pre-parse the diff with cacheKey for worker pool caching
  const fileDiff = useMemo(() => {
    const cacheKey = getCacheKey(fileName, original, modified, diffThemeKey);

    // Return cached diff if inputs haven't changed
    if (diffCacheRef.current?.key === cacheKey) {
      return diffCacheRef.current.fileDiff;
    }

    const oldFile: FileContents = {
      name: fileName,
      contents: original,
      lang: language as FileContents['lang'],
      cacheKey: `old-${cacheKey}`,
    };

    const newFile: FileContents = {
      name: fileName,
      contents: modified,
      lang: language as FileContents['lang'],
      cacheKey: `new-${cacheKey}`,
    };

    const diff = parseDiffFromFile(oldFile, newFile);

    // Cache the result
    diffCacheRef.current = { key: cacheKey, fileDiff: diff };

    return diff;
  }, [diffThemeKey, fileName, original, modified, language]);

  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' as const,
    lineDiffType: 'word-alt' as const,
    overflow: wrapLines ? ('wrap' as const) : ('scroll' as const),
    disableFileHeader: true,
    enableLineSelection: true,
    enableHoverUtility: false,
    onLineSelected: handleSelectionChange,
    unsafeCSS: WEBKIT_SCROLL_FIX_CSS,
  }), [darkTheme.metadata.id, isDark, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange]);

  if (typeof window === 'undefined') {
    return null;
  }

  // Extracted Comment Interface Content for reuse in Portal or In-Flow
  const renderCommentContent = () => {
    if (!selection) return null;
    return (
      
{/* Textarea - auto-grows from 1 line to max 5 lines */}