/ components / InteractiveDiffHunk.tsx
InteractiveDiffHunk.tsx
1 import { useState, useMemo } from 'react'; 2 import { MessageSquarePlus } from 'lucide-react'; 3 import { parseDiffLines, type DiffLineInfo } from '@/lib/diff-lines'; 4 import type { DiffHunk, DiffSide, PendingReviewComment } from '@/lib/types'; 5 import { FilePathLink } from '@/components/FilePathLink'; 6 import { 7 type CommentCallbacks, 8 parseShikiLines, 9 extractShikiStyles, 10 InlineCommentForm, 11 CommentBubble, 12 } from '@/components/shared-diff-utils'; 13 14 interface InteractiveDiffHunkGroupProps extends CommentCallbacks { 15 filePath: string; 16 hunks: DiffHunk[]; 17 pendingComments: PendingReviewComment[]; 18 slideIndex: number; 19 gitFileUrlBase?: string | null; 20 } 21 22 interface ParsedLine { 23 info: DiffLineInfo; 24 html: string; 25 } 26 27 function InteractiveHunk({ 28 hunk, 29 filePath, 30 pendingComments, 31 slideIndex, 32 onAddComment, 33 onRemoveComment, 34 onEditComment, 35 }: { 36 hunk: DiffHunk; 37 filePath: string; 38 pendingComments: PendingReviewComment[]; 39 slideIndex: number; 40 } & CommentCallbacks) { 41 const [activeFormLine, setActiveFormLine] = useState<{ line: number; side: DiffSide } | null>(null); 42 43 const lineInfos = useMemo(() => parseDiffLines(hunk.hunkHeader, hunk.content), [hunk.hunkHeader, hunk.content]); 44 45 const lineHtmls = useMemo(() => parseShikiLines(hunk.renderedHtml), [hunk.renderedHtml]); 46 const shikiStyles = useMemo(() => extractShikiStyles(hunk.renderedHtml), [hunk.renderedHtml]); 47 48 // Fall back to non-interactive rendering if we can't align parsed lines with HTML. 49 // Allow lineHtmls to have trailing extra elements (Shiki may add a trailing empty line span). 50 if (!lineHtmls || lineInfos.length === 0 || lineHtmls.length < lineInfos.length) { 51 if (lineHtmls && lineInfos.length > 0) { 52 console.warn( 53 `[InteractiveDiffHunk] Line count mismatch for ${filePath}: ` + 54 `parsed=${lineInfos.length} html=${lineHtmls.length}, falling back to non-interactive` 55 ); 56 } 57 return <div className="select-text" dangerouslySetInnerHTML={{ __html: hunk.renderedHtml }} />; 58 } 59 60 const lines: ParsedLine[] = lineInfos.map((info, i) => ({ 61 info, 62 html: lineHtmls[i], 63 })); 64 65 function handleAddComment(lineInfo: DiffLineInfo) { 66 setActiveFormLine({ line: lineInfo.lineNumber, side: lineInfo.side }); 67 } 68 69 function handleSubmitComment(body: string, lineInfo: DiffLineInfo) { 70 onAddComment({ 71 filePath, 72 line: lineInfo.lineNumber, 73 side: lineInfo.side, 74 body, 75 hunkHeader: hunk.hunkHeader, 76 codeSnippet: lineInfo.text, 77 slideIndex, 78 }); 79 setActiveFormLine(null); 80 } 81 82 const hasDiff = lineInfos.some((l) => l.type !== 'context'); 83 84 return ( 85 <pre className={`${shikiStyles.preClass} select-text`} style={shikiStyles.preStyle}> 86 <code style={{ display: 'block', fontSize: 0, minWidth: '100%', width: 'max-content' }}> 87 {lines.map((line, idx) => { 88 const lineComments = pendingComments.filter( 89 (c) => c.line === line.info.lineNumber && c.filePath === filePath && c.side === line.info.side 90 ); 91 const isFormActive = 92 activeFormLine?.line === line.info.lineNumber && activeFormLine.side === line.info.side; 93 94 const diffClass = line.info.type === 'add' ? 'diff add' : line.info.type === 'remove' ? 'diff remove' : ''; 95 96 return ( 97 <span key={idx}> 98 <span 99 className={`line ${diffClass} interactive-line`} 100 data-file-path={filePath} 101 data-line-number={line.info.lineNumber} 102 style={{ 103 display: 'flex', 104 alignItems: 'center', 105 fontSize: '0.8125rem', 106 lineHeight: '1.5', 107 paddingRight: '1.25rem', 108 }} 109 > 110 {/* Line number gutter */} 111 <span 112 className="line-number-gutter" 113 style={{ 114 display: 'inline-block', 115 width: '3.5ch', 116 textAlign: 'right', 117 paddingRight: '0.5ch', 118 color: 'var(--muted-foreground)', 119 userSelect: 'none', 120 flexShrink: 0, 121 cursor: 'pointer', 122 fontSize: '0.75rem', 123 }} 124 onClick={() => handleAddComment(line.info)} 125 title={`Comment on line ${line.info.lineNumber}`} 126 > 127 {line.info.lineNumber} 128 </span> 129 130 {/* Add comment icon (visible on hover via CSS) */} 131 <span 132 className="add-comment-icon" 133 style={{ 134 display: 'inline-flex', 135 alignItems: 'center', 136 width: '1.25rem', 137 flexShrink: 0, 138 opacity: 0, 139 cursor: 'pointer', 140 }} 141 onClick={() => handleAddComment(line.info)} 142 > 143 <MessageSquarePlus style={{ width: '0.75rem', height: '0.75rem', color: 'var(--ring)' }} /> 144 </span> 145 146 {/* Diff gutter character */} 147 {hasDiff && ( 148 <span 149 style={{ 150 display: 'inline-block', 151 width: '1ch', 152 marginRight: '1ch', 153 userSelect: 'none', 154 flexShrink: 0, 155 color: 156 line.info.type === 'add' ? '#3fb950' : line.info.type === 'remove' ? '#f85149' : 'transparent', 157 }} 158 > 159 {line.info.type === 'add' ? '+' : line.info.type === 'remove' ? '-' : ' '} 160 </span> 161 )} 162 163 {/* Code content */} 164 <span dangerouslySetInnerHTML={{ __html: line.html }} style={{ flex: 1, minWidth: 0 }} /> 165 </span> 166 167 {/* Comment form */} 168 {isFormActive && ( 169 <InlineCommentForm 170 onSubmit={(body) => handleSubmitComment(body, line.info)} 171 onCancel={() => setActiveFormLine(null)} 172 /> 173 )} 174 175 {/* Pending comments for this line */} 176 {lineComments.map((c) => ( 177 <CommentBubble key={c.id} comment={c} onRemove={onRemoveComment} onEdit={onEditComment} /> 178 ))} 179 </span> 180 ); 181 })} 182 </code> 183 </pre> 184 ); 185 } 186 187 export function InteractiveDiffHunkGroup({ 188 filePath, 189 hunks, 190 pendingComments, 191 slideIndex, 192 onAddComment, 193 onRemoveComment, 194 onEditComment, 195 gitFileUrlBase, 196 }: InteractiveDiffHunkGroupProps) { 197 // Count comments for this file 198 const fileCommentCount = pendingComments.filter((c) => c.filePath === filePath).length; 199 200 return ( 201 <div className="rounded-md border overflow-x-auto"> 202 <div className="bg-muted/50 px-3 py-2 font-mono text-xs text-muted-foreground border-b truncate flex items-center justify-between"> 203 <FilePathLink filePath={filePath} gitFileUrlBase={gitFileUrlBase} /> 204 {fileCommentCount > 0 && ( 205 <span className="ml-2 inline-flex items-center gap-1 text-[var(--ring)]"> 206 <MessageSquarePlus className="h-3 w-3" /> 207 {fileCommentCount} 208 </span> 209 )} 210 </div> 211 {hunks.map((hunk, i) => ( 212 <div key={i}> 213 {i > 0 && <div className="border-t border-dashed border-muted" />} 214 {hunk.hunkHeader && ( 215 <div className="bg-muted/30 px-3 py-1 font-mono text-xs text-muted-foreground border-b"> 216 {hunk.hunkHeader} 217 </div> 218 )} 219 <InteractiveHunk 220 hunk={hunk} 221 filePath={filePath} 222 pendingComments={pendingComments} 223 slideIndex={slideIndex} 224 onAddComment={onAddComment} 225 onRemoveComment={onRemoveComment} 226 onEditComment={onEditComment} 227 /> 228 </div> 229 ))} 230 </div> 231 ); 232 }