/ components / SplitDiffHunk.tsx
SplitDiffHunk.tsx
1 import { useState, useMemo } from 'react'; 2 import { MessageSquarePlus } from 'lucide-react'; 3 import { parseDiffLines, buildSplitRows, type DiffLineInfo, type SplitRow } 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 SplitDiffHunkGroupProps { 15 filePath: string; 16 hunks: DiffHunk[]; 17 pendingComments?: PendingReviewComment[]; 18 slideIndex?: number; 19 commentCallbacks?: CommentCallbacks; 20 gitFileUrlBase?: string | null; 21 } 22 23 function SplitDiffCell({ 24 info, 25 html, 26 cellClass, 27 isInteractive, 28 onClickLine, 29 }: { 30 info: DiffLineInfo | null; 31 html: string | null; 32 cellClass: string; 33 isInteractive: boolean; 34 onClickLine: (info: DiffLineInfo) => void; 35 }) { 36 const lineNum = 37 info?.type === 'context' ? info.lineNumber : info?.type === 'remove' ? info.baseLineNumber : info?.headLineNumber; 38 39 return ( 40 <> 41 {isInteractive && ( 42 <span 43 className={`split-diff-comment-icon ${cellClass}`} 44 style={{ 45 display: 'flex', 46 alignItems: 'center', 47 justifyContent: 'center', 48 cursor: info ? 'pointer' : 'default', 49 }} 50 onClick={() => info && onClickLine(info)} 51 > 52 {info && ( 53 <MessageSquarePlus 54 style={{ width: '0.75rem', height: '0.75rem', color: '#58a6ff', opacity: 0 }} 55 className="split-icon-hover" 56 /> 57 )} 58 </span> 59 )} 60 <span 61 className={`split-diff-line-num ${cellClass}`} 62 style={{ 63 textAlign: 'right', 64 paddingRight: '0.5ch', 65 color: 'rgba(255,255,255,0.3)', 66 userSelect: 'none', 67 fontSize: '0.75rem', 68 cursor: isInteractive && info ? 'pointer' : 'default', 69 }} 70 onClick={() => isInteractive && info && onClickLine(info)} 71 > 72 {lineNum ?? ''} 73 </span> 74 <span 75 className={`split-diff-code select-text ${cellClass}`} 76 style={{ paddingLeft: '0.5ch', paddingRight: '1ch', whiteSpace: 'pre' }} 77 > 78 {html ? <span dangerouslySetInnerHTML={{ __html: html }} /> : null} 79 </span> 80 </> 81 ); 82 } 83 84 function SplitHunk({ 85 hunk, 86 filePath, 87 pendingComments, 88 slideIndex, 89 commentCallbacks, 90 }: { 91 hunk: DiffHunk; 92 filePath: string; 93 pendingComments: PendingReviewComment[]; 94 slideIndex: number; 95 commentCallbacks?: CommentCallbacks; 96 }) { 97 const [activeFormKey, setActiveFormKey] = useState<string | null>(null); 98 99 const lineInfos = useMemo(() => parseDiffLines(hunk.hunkHeader, hunk.content), [hunk.hunkHeader, hunk.content]); 100 const lineHtmls = useMemo(() => parseShikiLines(hunk.renderedHtml), [hunk.renderedHtml]); 101 const shikiStyles = useMemo(() => extractShikiStyles(hunk.renderedHtml), [hunk.renderedHtml]); 102 103 const splitRows = useMemo(() => { 104 if (!lineHtmls || lineInfos.length === 0 || lineHtmls.length < lineInfos.length) return null; 105 return buildSplitRows(lineInfos, lineHtmls); 106 }, [lineInfos, lineHtmls]); 107 108 if (!splitRows) { 109 return <div className="select-text" dangerouslySetInnerHTML={{ __html: hunk.renderedHtml }} />; 110 } 111 112 const isInteractive = !!commentCallbacks; 113 114 function handleAddComment(info: DiffLineInfo) { 115 setActiveFormKey(`${info.lineNumber}:${info.side}`); 116 } 117 118 function handleSubmitComment(body: string, info: DiffLineInfo) { 119 commentCallbacks?.onAddComment({ 120 filePath, 121 line: info.lineNumber, 122 side: info.side, 123 body, 124 hunkHeader: hunk.hunkHeader, 125 codeSnippet: info.text, 126 slideIndex, 127 }); 128 setActiveFormKey(null); 129 } 130 131 const gridTemplateColumns = isInteractive ? '1.25rem 3.5ch 1fr 1px 1.25rem 3.5ch 1fr' : '3.5ch 1fr 1px 3.5ch 1fr'; 132 const gridColumnCount = isInteractive ? 7 : 5; 133 134 return ( 135 <div 136 className={shikiStyles.preClass} 137 style={{ 138 ...shikiStyles.preStyle, 139 overflow: 'auto', 140 fontFamily: 'var(--font-mono)', 141 fontSize: '0.8125rem', 142 lineHeight: '1.5', 143 }} 144 > 145 <div 146 className="split-diff-grid" 147 style={{ display: 'grid', gridTemplateColumns, minWidth: '100%', width: 'max-content' }} 148 > 149 {splitRows.map((row, rowIdx) => { 150 const leftInfo = row.left?.info ?? null; 151 const rightInfo = row.right?.info ?? null; 152 153 const leftCellClass = 154 leftInfo?.type === 'remove' ? 'split-diff-cell-remove' : !row.left ? 'split-diff-cell-empty' : ''; 155 const rightCellClass = 156 rightInfo?.type === 'add' ? 'split-diff-cell-add' : !row.right ? 'split-diff-cell-empty' : ''; 157 158 const leftFormKey = leftInfo ? `${leftInfo.lineNumber}:${leftInfo.side}` : null; 159 const rightFormKey = rightInfo ? `${rightInfo.lineNumber}:${rightInfo.side}` : null; 160 const showLeftForm = isInteractive && activeFormKey === leftFormKey; 161 const showRightForm = isInteractive && activeFormKey === rightFormKey; 162 163 const leftComments = 164 isInteractive && leftInfo 165 ? pendingComments.filter( 166 (c) => c.line === leftInfo.lineNumber && c.side === leftInfo.side && c.filePath === filePath 167 ) 168 : []; 169 const rightComments = 170 isInteractive && rightInfo 171 ? pendingComments.filter( 172 (c) => c.line === rightInfo.lineNumber && c.side === rightInfo.side && c.filePath === filePath 173 ) 174 : []; 175 176 const fullSpan = { gridColumn: `1 / ${gridColumnCount + 1}` }; 177 178 return ( 179 <SplitDiffRowFragment 180 key={rowIdx} 181 row={row} 182 leftCellClass={leftCellClass} 183 rightCellClass={rightCellClass} 184 isInteractive={isInteractive} 185 onClickLine={handleAddComment} 186 showLeftForm={showLeftForm} 187 showRightForm={showRightForm} 188 leftInfo={leftInfo} 189 rightInfo={rightInfo} 190 leftComments={leftComments} 191 rightComments={rightComments} 192 fullSpan={fullSpan} 193 onSubmitComment={handleSubmitComment} 194 onCancelForm={() => setActiveFormKey(null)} 195 commentCallbacks={commentCallbacks} 196 /> 197 ); 198 })} 199 </div> 200 </div> 201 ); 202 } 203 204 function SplitDiffRowFragment({ 205 row, 206 leftCellClass, 207 rightCellClass, 208 isInteractive, 209 onClickLine, 210 showLeftForm, 211 showRightForm, 212 leftInfo, 213 rightInfo, 214 leftComments, 215 rightComments, 216 fullSpan, 217 onSubmitComment, 218 onCancelForm, 219 commentCallbacks, 220 }: { 221 row: SplitRow; 222 leftCellClass: string; 223 rightCellClass: string; 224 isInteractive: boolean; 225 onClickLine: (info: DiffLineInfo) => void; 226 showLeftForm: boolean | string | null; 227 showRightForm: boolean | string | null; 228 leftInfo: DiffLineInfo | null; 229 rightInfo: DiffLineInfo | null; 230 leftComments: PendingReviewComment[]; 231 rightComments: PendingReviewComment[]; 232 fullSpan: React.CSSProperties; 233 onSubmitComment: (body: string, info: DiffLineInfo) => void; 234 onCancelForm: () => void; 235 commentCallbacks?: CommentCallbacks; 236 }) { 237 return ( 238 <> 239 <SplitDiffCell 240 info={row.left?.info ?? null} 241 html={row.left?.html ?? null} 242 cellClass={leftCellClass} 243 isInteractive={isInteractive} 244 onClickLine={onClickLine} 245 /> 246 247 <span className="split-diff-separator" /> 248 249 <SplitDiffCell 250 info={row.right?.info ?? null} 251 html={row.right?.html ?? null} 252 cellClass={rightCellClass} 253 isInteractive={isInteractive} 254 onClickLine={onClickLine} 255 /> 256 257 {showLeftForm && leftInfo && ( 258 <div style={fullSpan}> 259 <InlineCommentForm onSubmit={(body) => onSubmitComment(body, leftInfo)} onCancel={onCancelForm} /> 260 </div> 261 )} 262 {commentCallbacks && 263 leftComments.map((c) => ( 264 <div key={c.id} style={fullSpan}> 265 <CommentBubble 266 comment={c} 267 onRemove={commentCallbacks.onRemoveComment} 268 onEdit={commentCallbacks.onEditComment} 269 /> 270 </div> 271 ))} 272 {showRightForm && rightInfo && ( 273 <div style={fullSpan}> 274 <InlineCommentForm onSubmit={(body) => onSubmitComment(body, rightInfo)} onCancel={onCancelForm} /> 275 </div> 276 )} 277 {commentCallbacks && 278 rightComments.map((c) => ( 279 <div key={c.id} style={fullSpan}> 280 <CommentBubble 281 comment={c} 282 onRemove={commentCallbacks.onRemoveComment} 283 onEdit={commentCallbacks.onEditComment} 284 /> 285 </div> 286 ))} 287 </> 288 ); 289 } 290 291 export function SplitDiffHunkGroup({ 292 filePath, 293 hunks, 294 pendingComments = [], 295 slideIndex = 0, 296 commentCallbacks, 297 gitFileUrlBase, 298 }: SplitDiffHunkGroupProps) { 299 const fileCommentCount = pendingComments.filter((c) => c.filePath === filePath).length; 300 301 return ( 302 <div className="rounded-md border overflow-x-auto"> 303 <div className="bg-muted/50 px-3 py-2 font-mono text-xs text-muted-foreground border-b truncate flex items-center justify-between"> 304 <FilePathLink filePath={filePath} gitFileUrlBase={gitFileUrlBase} /> 305 {commentCallbacks && fileCommentCount > 0 && ( 306 <span className="ml-2 inline-flex items-center gap-1 text-blue-400"> 307 <MessageSquarePlus className="h-3 w-3" /> 308 {fileCommentCount} 309 </span> 310 )} 311 </div> 312 {hunks.map((hunk, i) => ( 313 <div key={i}> 314 {i > 0 && <div className="border-t border-dashed border-muted" />} 315 {hunk.hunkHeader && ( 316 <div className="bg-muted/30 px-3 py-1 font-mono text-xs text-muted-foreground border-b"> 317 {hunk.hunkHeader} 318 </div> 319 )} 320 <SplitHunk 321 hunk={hunk} 322 filePath={filePath} 323 pendingComments={pendingComments} 324 slideIndex={slideIndex} 325 commentCallbacks={commentCallbacks} 326 /> 327 </div> 328 ))} 329 </div> 330 ); 331 }