/ components / SubmitReviewDialog.tsx
SubmitReviewDialog.tsx
1 import { useState, useEffect } from 'react'; 2 import { AlertTriangle, Check, ExternalLink, MessageSquare, ShieldCheck, ShieldX } from 'lucide-react'; 3 import { Button } from '@/components/ui/button'; 4 import { 5 Dialog, 6 DialogContent, 7 DialogDescription, 8 DialogFooter, 9 DialogHeader, 10 DialogTitle, 11 } from '@/components/ui/dialog'; 12 import type { PendingReviewComment, ReviewEvent } from '@/lib/types'; 13 14 interface Props { 15 open: boolean; 16 onOpenChange: (open: boolean) => void; 17 comments: PendingReviewComment[]; 18 prUrl: string; 19 headSha?: string; 20 isOwnPr: boolean; 21 onSubmit: (event: ReviewEvent, body: string) => Promise<{ reviewUrl: string; droppedCommentCount: number }>; 22 } 23 24 export function SubmitReviewDialog({ open, onOpenChange, comments, headSha, isOwnPr, onSubmit }: Props) { 25 const [body, setBody] = useState(''); 26 const [submitting, setSubmitting] = useState(false); 27 const [error, setError] = useState<string | null>(null); 28 const [successUrl, setSuccessUrl] = useState<string | null>(null); 29 const [droppedCount, setDroppedCount] = useState(0); 30 const [submittedCount, setSubmittedCount] = useState(0); 31 const [reviewSignature, setReviewSignature] = useState(true); 32 33 useEffect(() => { 34 if (!open) return; 35 void window.electronAPI.loadPreferences().then((prefs) => { 36 setReviewSignature(prefs.reviewSignature); 37 }); 38 }, [open]); 39 40 // Group comments by file for the summary 41 const fileGroups = new Map<string, PendingReviewComment[]>(); 42 for (const c of comments) { 43 const existing = fileGroups.get(c.filePath); 44 if (existing) { 45 existing.push(c); 46 } else { 47 fileGroups.set(c.filePath, [c]); 48 } 49 } 50 51 async function handleSubmit(event: ReviewEvent) { 52 setSubmitting(true); 53 setError(null); 54 try { 55 setSubmittedCount(comments.length); 56 const result = await onSubmit(event, body); 57 setSuccessUrl(result.reviewUrl); 58 setDroppedCount(result.droppedCommentCount); 59 } catch (err) { 60 setError(err instanceof Error ? err.message : 'Failed to submit review'); 61 } finally { 62 setSubmitting(false); 63 } 64 } 65 66 function handleClose() { 67 if (successUrl) { 68 setSuccessUrl(null); 69 setBody(''); 70 } 71 onOpenChange(false); 72 } 73 74 // Success state 75 if (successUrl) { 76 return ( 77 <Dialog open={open} onOpenChange={handleClose}> 78 <DialogContent className="sm:max-w-md"> 79 <DialogHeader> 80 <DialogTitle className="flex items-center gap-2"> 81 <Check className="h-5 w-5 text-green-500" /> 82 Review submitted 83 </DialogTitle> 84 <DialogDescription> 85 Your review with {submittedCount} comment{submittedCount !== 1 ? 's' : ''} has been posted. 86 </DialogDescription> 87 </DialogHeader> 88 {droppedCount > 0 && ( 89 <div className="flex items-start gap-2 rounded-md border border-yellow-800/50 bg-yellow-950/30 p-3 text-sm text-yellow-200"> 90 <AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-yellow-500" /> 91 <span> 92 {droppedCount} comment{droppedCount !== 1 ? 's were' : ' was'} on lines outside the diff range and{' '} 93 {droppedCount !== 1 ? 'were' : 'was'} included in the review body instead. 94 </span> 95 </div> 96 )} 97 <div className="flex justify-center"> 98 <a 99 href={successUrl} 100 target="_blank" 101 rel="noopener noreferrer" 102 className="inline-flex items-center gap-1.5 text-sm text-blue-400 hover:text-blue-300 underline underline-offset-2" 103 > 104 View on GitHub <ExternalLink className="h-3 w-3" /> 105 </a> 106 </div> 107 <DialogFooter> 108 <Button onClick={handleClose}>Done</Button> 109 </DialogFooter> 110 </DialogContent> 111 </Dialog> 112 ); 113 } 114 115 return ( 116 <Dialog open={open} onOpenChange={handleClose}> 117 <DialogContent className="sm:max-w-xl max-h-[80vh] flex flex-col"> 118 <DialogHeader> 119 <DialogTitle>Submit review</DialogTitle> 120 <DialogDescription> 121 {comments.length} comment{comments.length !== 1 ? 's' : ''} across {fileGroups.size} file 122 {fileGroups.size !== 1 ? 's' : ''} 123 </DialogDescription> 124 </DialogHeader> 125 126 {!headSha && ( 127 <div className="flex items-start gap-2 rounded-md border border-yellow-800/50 bg-yellow-950/30 p-3 text-sm text-yellow-200"> 128 <AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-yellow-500" /> 129 <span> 130 This review was loaded from history without a commit reference. Line comments may land on wrong lines if 131 the PR has been updated. 132 </span> 133 </div> 134 )} 135 136 {/* Comment list */} 137 {comments.length > 0 && ( 138 <div className="flex-1 overflow-y-auto space-y-3 min-h-0"> 139 {Array.from(fileGroups.entries()).map(([filePath, fileComments]) => ( 140 <details key={filePath} className="group" open> 141 <summary className="cursor-pointer text-xs font-mono text-muted-foreground hover:text-foreground select-none flex items-center gap-1"> 142 <span className="group-open:rotate-90 inline-block transition-transform">▶</span> 143 {filePath} 144 <span className="text-blue-400 ml-1">({fileComments.length})</span> 145 </summary> 146 <div className="mt-1 ml-3 space-y-1"> 147 {fileComments.map((c) => ( 148 <div key={c.id} className="text-xs border rounded p-2 bg-muted/20"> 149 <div className="flex items-center gap-2 text-muted-foreground mb-1"> 150 <span>Line {c.line}</span> 151 <code className="text-[10px] bg-muted/50 px-1 rounded truncate max-w-[200px]"> 152 {c.codeSnippet} 153 </code> 154 </div> 155 <pre className="text-xs whitespace-pre-wrap break-words font-mono">{c.body}</pre> 156 </div> 157 ))} 158 </div> 159 </details> 160 ))} 161 </div> 162 )} 163 164 {/* Review body */} 165 <div className="flex flex-col gap-2"> 166 <div> 167 <label className="text-xs text-muted-foreground mb-1 block">Review summary (optional)</label> 168 <textarea 169 value={body} 170 onChange={(e) => setBody(e.target.value)} 171 placeholder="Overall feedback..." 172 className="w-full min-h-[80px] bg-transparent text-sm resize-y border rounded p-2 focus:outline-none focus:ring-1 focus:ring-ring" 173 /> 174 </div> 175 <label className="flex items-center gap-2 cursor-pointer select-none"> 176 <input 177 type="checkbox" 178 checked={reviewSignature} 179 onChange={(e) => { 180 setReviewSignature(e.target.checked); 181 void window.electronAPI 182 .loadPreferences() 183 .then((prefs) => window.electronAPI.savePreferences({ ...prefs, reviewSignature: e.target.checked })); 184 }} 185 className="rounded border" 186 /> 187 <span className="text-xs text-muted-foreground">Add “Reviewed using gnosis.to” signature</span> 188 </label> 189 </div> 190 191 {error && <p className="text-sm text-destructive">{error}</p>} 192 193 <DialogFooter className="flex-col gap-2 sm:flex-col sm:gap-2"> 194 {isOwnPr && ( 195 <p className="text-xs text-muted-foreground text-center"> 196 You can't approve or request changes on your own PR. 197 </p> 198 )} 199 <div className="flex flex-row justify-end gap-2"> 200 <Button 201 variant="outline" 202 size="sm" 203 onClick={() => handleSubmit('COMMENT')} 204 disabled={submitting || (comments.length === 0 && !body.trim())} 205 > 206 <MessageSquare className="h-3.5 w-3.5 mr-1.5" /> 207 Comment 208 </Button> 209 <Button 210 variant="outline" 211 size="sm" 212 className="border-green-800 text-green-400 hover:bg-green-950/50" 213 onClick={() => handleSubmit('APPROVE')} 214 disabled={submitting || isOwnPr} 215 > 216 <ShieldCheck className="h-3.5 w-3.5 mr-1.5" /> 217 Approve 218 </Button> 219 <Button 220 variant="outline" 221 size="sm" 222 className="border-red-800 text-red-400 hover:bg-red-950/50" 223 onClick={() => handleSubmit('REQUEST_CHANGES')} 224 disabled={submitting || isOwnPr || (comments.length === 0 && !body.trim())} 225 > 226 <ShieldX className="h-3.5 w-3.5 mr-1.5" /> 227 Request changes 228 </Button> 229 </div> 230 </DialogFooter> 231 </DialogContent> 232 </Dialog> 233 ); 234 }