/ 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, 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<number | 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(lineInfo.lineNumber); 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 90 ); 91 const isFormActive = activeFormLine === line.info.lineNumber; 92 93 const diffClass = line.info.type === 'add' ? 'diff add' : line.info.type === 'remove' ? 'diff remove' : ''; 94 95 return ( 96 <span key={idx}> 97 <span 98 className={`line ${diffClass} interactive-line`} 99 data-file-path={filePath} 100 data-line-number={line.info.lineNumber} 101 style={{ 102 display: 'flex', 103 alignItems: 'center', 104 fontSize: '0.8125rem', 105 lineHeight: '1.5', 106 paddingRight: '1.25rem', 107 }} 108 > 109 {/* Line number gutter */} 110 <span 111 className="line-number-gutter" 112 style={{ 113 display: 'inline-block', 114 width: '3.5ch', 115 textAlign: 'right', 116 paddingRight: '0.5ch', 117 color: 'rgba(255,255,255,0.3)', 118 userSelect: 'none', 119 flexShrink: 0, 120 cursor: 'pointer', 121 fontSize: '0.75rem', 122 }} 123 onClick={() => handleAddComment(line.info)} 124 title={`Comment on line ${line.info.lineNumber}`} 125 > 126 {line.info.lineNumber} 127 </span> 128 129 {/* Add comment icon (visible on hover via CSS) */} 130 <span 131 className="add-comment-icon" 132 style={{ 133 display: 'inline-flex', 134 alignItems: 'center', 135 width: '1.25rem', 136 flexShrink: 0, 137 opacity: 0, 138 cursor: 'pointer', 139 }} 140 onClick={() => handleAddComment(line.info)} 141 > 142 <MessageSquarePlus style={{ width: '0.75rem', height: '0.75rem', color: '#58a6ff' }} /> 143 </span> 144 145 {/* Diff gutter character */} 146 {hasDiff && ( 147 <span 148 style={{ 149 display: 'inline-block', 150 width: '1ch', 151 marginRight: '1ch', 152 userSelect: 'none', 153 flexShrink: 0, 154 color: 155 line.info.type === 'add' ? '#3fb950' : line.info.type === 'remove' ? '#f85149' : 'transparent', 156 }} 157 > 158 {line.info.type === 'add' ? '+' : line.info.type === 'remove' ? '-' : ' '} 159 </span> 160 )} 161 162 {/* Code content */} 163 <span dangerouslySetInnerHTML={{ __html: line.html }} style={{ flex: 1, minWidth: 0 }} /> 164 </span> 165 166 {/* Comment form */} 167 {isFormActive && ( 168 <InlineCommentForm 169 onSubmit={(body) => handleSubmitComment(body, line.info)} 170 onCancel={() => setActiveFormLine(null)} 171 /> 172 )} 173 174 {/* Pending comments for this line */} 175 {lineComments.map((c) => ( 176 <CommentBubble key={c.id} comment={c} onRemove={onRemoveComment} onEdit={onEditComment} /> 177 ))} 178 </span> 179 ); 180 })} 181 </code> 182 </pre> 183 ); 184 } 185 186 export function InteractiveDiffHunkGroup({ 187 filePath, 188 hunks, 189 pendingComments, 190 slideIndex, 191 onAddComment, 192 onRemoveComment, 193 onEditComment, 194 gitFileUrlBase, 195 }: InteractiveDiffHunkGroupProps) { 196 // Count comments for this file 197 const fileCommentCount = pendingComments.filter((c) => c.filePath === filePath).length; 198 199 return ( 200 <div className="rounded-md border overflow-x-auto"> 201 <div className="bg-muted/50 px-3 py-2 font-mono text-xs text-muted-foreground border-b truncate flex items-center justify-between"> 202 <FilePathLink filePath={filePath} gitFileUrlBase={gitFileUrlBase} /> 203 {fileCommentCount > 0 && ( 204 <span className="ml-2 inline-flex items-center gap-1 text-blue-400"> 205 <MessageSquarePlus className="h-3 w-3" /> 206 {fileCommentCount} 207 </span> 208 )} 209 </div> 210 {hunks.map((hunk, i) => ( 211 <div key={i}> 212 {i > 0 && <div className="border-t border-dashed border-muted" />} 213 {hunk.hunkHeader && ( 214 <div className="bg-muted/30 px-3 py-1 font-mono text-xs text-muted-foreground border-b"> 215 {hunk.hunkHeader} 216 </div> 217 )} 218 <InteractiveHunk 219 hunk={hunk} 220 filePath={filePath} 221 pendingComments={pendingComments} 222 slideIndex={slideIndex} 223 onAddComment={onAddComment} 224 onRemoveComment={onRemoveComment} 225 onEditComment={onEditComment} 226 /> 227 </div> 228 ))} 229 </div> 230 ); 231 }