/ components / shared-diff-utils.tsx
shared-diff-utils.tsx
1 import { useState, useRef, useEffect } from 'react'; 2 import { Pencil, Trash2 } from 'lucide-react'; 3 import { Button } from '@/components/ui/button'; 4 import type { PendingReviewComment, DiffSide } from '@/lib/types'; 5 6 export interface CommentCallbacks { 7 onAddComment: (params: { 8 filePath: string; 9 line: number; 10 side: DiffSide; 11 body: string; 12 hunkHeader: string; 13 codeSnippet: string; 14 slideIndex: number; 15 }) => void; 16 onRemoveComment: (id: string) => void; 17 onEditComment: (id: string, body: string) => void; 18 } 19 20 /** 21 * Parse the Shiki-rendered HTML to extract individual line elements. 22 * Shiki wraps each line in <span class="line">...</span> inside <code> inside <pre>. 23 */ 24 export function parseShikiLines(renderedHtml: string): string[] | null { 25 try { 26 const parser = new DOMParser(); 27 const doc = parser.parseFromString(renderedHtml, 'text/html'); 28 const lines = doc.querySelectorAll('span.line'); 29 if (lines.length === 0) return null; 30 return Array.from(lines).map((el) => el.innerHTML); 31 } catch { 32 return null; 33 } 34 } 35 36 /** 37 * Extract the Shiki theme styles from the rendered <pre> element so we can 38 * re-apply them when rendering individual lines outside the original tree. 39 */ 40 export function extractShikiStyles(renderedHtml: string): { preStyle: Record<string, string>; preClass: string } { 41 try { 42 const parser = new DOMParser(); 43 const doc = parser.parseFromString(renderedHtml, 'text/html'); 44 const pre = doc.querySelector('pre'); 45 const styleStr = pre?.getAttribute('style') ?? ''; 46 const preStyle: Record<string, string> = {}; 47 for (const decl of styleStr.split(';')) { 48 const [prop, ...rest] = decl.split(':'); 49 if (!prop.trim() || rest.length === 0) continue; 50 const camel = prop.trim().replace(/-([a-z])/g, (_match, c: string) => c.toUpperCase()); 51 preStyle[camel] = rest.join(':').trim(); 52 } 53 return { 54 preStyle, 55 preClass: pre?.getAttribute('class') ?? '', 56 }; 57 } catch { 58 return { preStyle: {}, preClass: '' }; 59 } 60 } 61 62 export function InlineCommentForm({ 63 onSubmit, 64 onCancel, 65 initialBody, 66 }: { 67 onSubmit: (body: string) => void; 68 onCancel: () => void; 69 initialBody?: string; 70 }) { 71 const [body, setBody] = useState(initialBody ?? ''); 72 const textareaRef = useRef<HTMLTextAreaElement>(null); 73 74 useEffect(() => { 75 textareaRef.current?.focus(); 76 }, []); 77 78 function handleKeyDown(e: React.KeyboardEvent) { 79 if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { 80 e.preventDefault(); 81 if (body.trim()) onSubmit(body.trim()); 82 } 83 if (e.key === 'Escape') { 84 e.preventDefault(); 85 onCancel(); 86 } 87 } 88 89 return ( 90 <div className="mx-2 my-1.5 border rounded-md bg-muted/30 p-2"> 91 <textarea 92 ref={textareaRef} 93 value={body} 94 onChange={(e) => setBody(e.target.value)} 95 onKeyDown={handleKeyDown} 96 placeholder="Leave a comment... (Cmd+Enter to add, Esc to cancel)" 97 className="w-full min-h-[60px] bg-transparent text-sm font-mono resize-y border rounded p-2 focus:outline-none focus:ring-1 focus:ring-ring" 98 /> 99 <div className="flex justify-end gap-1.5 mt-1.5"> 100 <Button variant="ghost" size="sm" onClick={onCancel}> 101 Cancel 102 </Button> 103 <Button size="sm" onClick={() => body.trim() && onSubmit(body.trim())} disabled={!body.trim()}> 104 Add comment 105 </Button> 106 </div> 107 </div> 108 ); 109 } 110 111 export function CommentBubble({ 112 comment, 113 onRemove, 114 onEdit, 115 }: { 116 comment: PendingReviewComment; 117 onRemove: (id: string) => void; 118 onEdit: (id: string, body: string) => void; 119 }) { 120 const [editing, setEditing] = useState(false); 121 122 if (editing) { 123 return ( 124 <InlineCommentForm 125 initialBody={comment.body} 126 onSubmit={(body) => { 127 onEdit(comment.id, body); 128 setEditing(false); 129 }} 130 onCancel={() => setEditing(false)} 131 /> 132 ); 133 } 134 135 return ( 136 <div className="mx-2 my-1 border rounded-md bg-blue-950/30 border-blue-800/30 p-2 flex gap-2 group"> 137 <pre className="flex-1 text-xs font-mono whitespace-pre-wrap break-words text-blue-200">{comment.body}</pre> 138 <div className="flex flex-col gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"> 139 <button 140 onClick={() => setEditing(true)} 141 className="p-0.5 rounded hover:bg-muted/50 text-muted-foreground hover:text-foreground" 142 title="Edit" 143 > 144 <Pencil className="h-3 w-3" /> 145 </button> 146 <button 147 onClick={() => onRemove(comment.id)} 148 className="p-0.5 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive" 149 title="Delete" 150 > 151 <Trash2 className="h-3 w-3" /> 152 </button> 153 </div> 154 </div> 155 ); 156 }