import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { File, type LineAnnotation } from '@pierre/diffs/react'; import type { SelectedLineRange as PierreSelectedLineRange, LineEventBaseProps } from '@pierre/diffs'; import { PopoutDialog } from './PopoutDialog'; import { useTheme } from './ThemeProvider'; import { CommentPopover } from './CommentPopover'; import { ImageThumbnail } from './ImageThumbnail'; import type { CodeAnnotation, ImageAttachment } from '../types'; import { copyTextToClipboard } from '../utils/clipboard'; export interface CodeFileAnnotationInput { filePath: string; lineStart: number; lineEnd: number; text: string; images?: ImageAttachment[]; originalCode: string; } interface CodeFilePopoutProps { open: boolean; onClose: () => void; filepath: string; contents: string; prerenderedHTML?: string; error?: string; requestedPath?: string; annotations?: CodeAnnotation[]; selectedAnnotationId?: string | null; onAddAnnotation?: (annotation: CodeFileAnnotationInput) => void; onEditAnnotation?: (id: string, updates: Partial) => void; onDeleteAnnotation?: (id: string) => void; onSelectAnnotation?: (id: string | null) => void; container?: HTMLElement | null; } interface PendingComment { range: { start: number; end: number }; contextText: string; originalCode: string; anchorEl?: HTMLElement; anchorRect?: DOMRect; } const gutterButtonStyle: React.CSSProperties = { appearance: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', width: '1lh', height: '1lh', fontSize: 'var(--diffs-font-size, 13px)', lineHeight: 'var(--diffs-line-height, 20px)', border: 'none', borderRadius: 4, backgroundColor: 'var(--diffs-modified-base)', color: 'var(--diffs-bg)', cursor: 'pointer', position: 'relative', zIndex: 4, padding: 0, marginRight: 'calc(1ch - 1lh)', }; function getThemeColors(): { bg: string; fg: string } { try { const styles = getComputedStyle(document.documentElement); return { bg: styles.getPropertyValue('--background').trim(), fg: styles.getPropertyValue('--foreground').trim(), }; } catch { return { bg: '', fg: '' }; } } function buildPierreCSS(mode: 'dark' | 'light', bg: string, fg: string): string { if (!bg || !fg) return ''; return ` :host { color-scheme: ${mode}; height: 100% !important; } :host, [data-diff], [data-file], [data-diffs-header], [data-error-wrapper], [data-virtualizer-buffer] { --diffs-bg: ${bg} !important; --diffs-fg: ${fg} !important; --diffs-dark-bg: ${bg}; --diffs-light-bg: ${bg}; --diffs-dark: ${fg}; --diffs-light: ${fg}; } pre, code { background-color: ${bg} !important; } [data-column-number] { background-color: ${bg} !important; } [data-file] { height: 100% !important; } [data-code] { height: 100% !important; overflow-y: auto !important; align-content: start; } [data-line] { cursor: pointer; } `; } function getLineSlice(contents: string, start: number, end: number): string { return contents .split('\n') .slice(Math.max(0, start - 1), Math.max(0, end)) .join('\n'); } function lineLabel(start: number, end: number): string { return start === end ? `line ${start}` : `lines ${start}-${end}`; } function getLineNumberFromSelectionNode(node: Node | null): number | null { let current: Node | null = node; if (current?.nodeType === Node.TEXT_NODE) current = current.parentNode; while (current) { if (current instanceof HTMLElement) { const line = current.closest('[data-line]')?.getAttribute('data-line'); if (line) { const parsed = Number(line); return Number.isFinite(parsed) ? parsed : null; } } current = current.parentNode; } return null; } function getPierreSelection(root: HTMLElement | null): Selection | null { const shadowRoot = root?.querySelector('diffs-container')?.shadowRoot; const shadowSelection = (shadowRoot as (ShadowRoot & { getSelection?: () => Selection | null }) | null) ?.getSelection?.(); return shadowSelection && !shadowSelection.isCollapsed ? shadowSelection : window.getSelection(); } const CodeInlineAnnotation: React.FC<{ annotation: CodeAnnotation; isSelected: boolean; onSelect?: (id: string | null) => void; onEdit?: (id: string, updates: Partial) => void; onDelete?: (id: string) => void; }> = ({ annotation, isSelected, onSelect, onEdit, onDelete }) => { const [isEditing, setIsEditing] = useState(false); const [editText, setEditText] = useState(annotation.text ?? ''); const textareaRef = useRef(null); useEffect(() => { if (!isEditing) setEditText(annotation.text ?? ''); }, [annotation.text, isEditing]); useEffect(() => { if (isEditing) { textareaRef.current?.focus(); textareaRef.current?.select(); } }, [isEditing]); const save = () => { onEdit?.(annotation.id, { text: editText }); setIsEditing(false); }; return (
onSelect?.(annotation.id)} className={`group my-2 mx-3 rounded-lg border px-3 py-2 text-xs shadow-sm cursor-pointer transition-colors ${ isSelected ? 'border-primary/50' : 'border-border hover:border-border/80' }`} style={{ backgroundColor: isSelected ? 'color-mix(in oklab, var(--primary) 12%, var(--popover))' : 'var(--popover)', color: 'var(--foreground)', opacity: 1, }} >
Comment {lineLabel(annotation.lineStart, annotation.lineEnd)} {annotation.author && by {annotation.author}}
{onEdit && !isEditing && ( )} {onDelete && ( )}
{isEditing ? (