/ components / SlideView.tsx
SlideView.tsx
1 import { useRef, useCallback } from 'react'; 2 import { Group as PanelGroup, Panel, Separator as PanelResizeHandle } from 'react-resizable-panels'; 3 import { Eye, MessageCircle } from 'lucide-react'; 4 import { Badge } from '@/components/ui/badge'; 5 import { Card, CardContent } from '@/components/ui/card'; 6 import { Button } from '@/components/ui/button'; 7 import { DiffHunkGroup } from '@/components/DiffHunk'; 8 import { InteractiveDiffHunkGroup } from '@/components/InteractiveDiffHunk'; 9 import { SplitDiffHunkGroup } from '@/components/SplitDiffHunk'; 10 import { FilePathLink } from '@/components/FilePathLink'; 11 import { Markdown } from '@/components/Markdown'; 12 import { MermaidDiagram } from '@/components/MermaidDiagram'; 13 import { slideTypeConfig } from '@/lib/constants'; 14 import type { CommentCallbacks } from '@/components/shared-diff-utils'; 15 import type { Slide, DiffHunk, PendingReviewComment, Preferences, ReviewCheck } from '@/lib/types'; 16 17 interface Props { 18 slide: Slide; 19 slideNumber: number; 20 totalSlides: number; 21 pendingComments?: PendingReviewComment[]; 22 commentCallbacks?: CommentCallbacks; 23 diffLayout: Preferences['diffLayout']; 24 onDiffLayoutChange: (layout: Preferences['diffLayout']) => void; 25 onAskQuestion?: () => void; 26 gitFileUrlBase?: string | null; 27 excludedFiles?: Set<string>; 28 } 29 30 // Group hunks by filePath so we can render them under a single file header 31 function groupHunksByFile(hunks: DiffHunk[]): { filePath: string; hunks: DiffHunk[] }[] { 32 const map = new Map<string, DiffHunk[]>(); 33 for (const hunk of hunks) { 34 const existing = map.get(hunk.filePath); 35 if (existing) { 36 existing.push(hunk); 37 } else { 38 map.set(hunk.filePath, [hunk]); 39 } 40 } 41 return Array.from(map.entries()).map(([filePath, hunks]) => ({ filePath, hunks })); 42 } 43 44 function DiffLayoutToggle({ 45 value, 46 onChange, 47 }: { 48 value: Preferences['diffLayout']; 49 onChange: (v: Preferences['diffLayout']) => void; 50 }) { 51 return ( 52 <div className="inline-flex rounded-md border border-border bg-muted/30 p-0.5 text-xs"> 53 <button 54 className={`px-2.5 py-1 rounded-sm transition-colors ${ 55 value === 'unified' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground' 56 }`} 57 onClick={() => onChange('unified')} 58 > 59 Unified 60 </button> 61 <button 62 className={`px-2.5 py-1 rounded-sm transition-colors ${ 63 value === 'split' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground' 64 }`} 65 onClick={() => onChange('split')} 66 > 67 Split 68 </button> 69 </div> 70 ); 71 } 72 73 export function SlideView({ 74 slide, 75 slideNumber, 76 pendingComments, 77 commentCallbacks, 78 diffLayout, 79 onDiffLayoutChange, 80 onAskQuestion, 81 gitFileUrlBase, 82 excludedFiles, 83 }: Props) { 84 const typeConfig = slideTypeConfig[slide.slideType]; 85 const Icon = typeConfig.icon; 86 const groupedHunks = groupHunksByFile(slide.diffHunks); 87 const rightPanelRef = useRef<HTMLDivElement>(null); 88 89 const handleCheckClick = useCallback((check: ReviewCheck) => { 90 if (!check.filePath || !check.startLine) return; 91 const container = rightPanelRef.current; 92 if (!container) return; 93 94 const selector = `[data-file-path="${CSS.escape(check.filePath)}"][data-line-number="${check.startLine}"]`; 95 const target = container.querySelector(selector); 96 if (!target) return; 97 98 target.scrollIntoView({ behavior: 'smooth', block: 'center' }); 99 target.classList.remove('check-highlight'); 100 // Force reflow to restart animation if clicking the same item again 101 void (target as HTMLElement).offsetWidth; 102 target.classList.add('check-highlight'); 103 }, []); 104 105 return ( 106 <PanelGroup orientation="horizontal" className="flex flex-1 overflow-hidden"> 107 {/* Left panel — narrative */} 108 <Panel defaultSize={40} minSize={25} className="overflow-y-auto min-h-0"> 109 <div className="p-6 flex flex-col gap-5"> 110 <div className="flex items-center gap-2 flex-wrap"> 111 <Badge variant="outline" className={`gap-1 ${typeConfig.className}`}> 112 <Icon className="h-3 w-3" /> 113 {typeConfig.label} 114 </Badge> 115 </div> 116 117 <h2 className="text-lg font-semibold leading-tight font-display select-text">{slide.title}</h2> 118 119 <Markdown className="text-sm text-muted-foreground leading-relaxed">{slide.narrative}</Markdown> 120 121 {/* Review focus */} 122 <div className="review-focus-callout rounded-lg border-l-2 border-l-primary bg-primary/[0.06] px-4 py-3"> 123 <p className="text-xs uppercase tracking-wider text-primary/70 flex items-center gap-1.5 mb-2"> 124 <Eye className="h-3 w-3" /> 125 What to check 126 </p> 127 {slide.reviewChecks && slide.reviewChecks.length > 0 ? ( 128 <ul className="text-sm review-focus-content" style={{ listStyle: 'none', paddingLeft: 0 }}> 129 {slide.reviewChecks.map((check, i) => { 130 const isClickable = !!(check.filePath && check.startLine != null && check.startLine > 0); 131 return ( 132 <li 133 key={i} 134 className={isClickable ? 'cursor-pointer hover:bg-muted/50 rounded-sm transition-colors' : ''} 135 onClick={isClickable ? () => handleCheckClick(check) : undefined} 136 > 137 {check.text} 138 </li> 139 ); 140 })} 141 </ul> 142 ) : ( 143 <Markdown className="text-sm review-focus-content">{slide.reviewFocus ?? ''}</Markdown> 144 )} 145 </div> 146 147 {/* Affected files */} 148 {slide.affectedFiles.length > 0 && ( 149 <div> 150 <p className="text-xs uppercase tracking-wider text-muted-foreground mb-2">Affected files</p> 151 <ul className="space-y-1"> 152 {slide.affectedFiles.map((f) => ( 153 <li key={f} className="font-mono text-xs text-muted-foreground truncate"> 154 {excludedFiles?.has(f) ? ( 155 <span className="italic">{f} (excluded)</span> 156 ) : ( 157 <FilePathLink filePath={f} gitFileUrlBase={gitFileUrlBase} /> 158 )} 159 </li> 160 ))} 161 </ul> 162 </div> 163 )} 164 165 {/* Context snippets */} 166 {slide.contextSnippets.length > 0 && ( 167 <details className="group"> 168 <summary className="cursor-pointer text-xs uppercase tracking-wider text-muted-foreground hover:text-foreground select-none list-none flex items-center gap-1"> 169 <span className="group-open:rotate-90 inline-block transition-transform">▶</span> 170 Codebase context 171 </summary> 172 <div className="mt-3 space-y-3"> 173 {slide.contextSnippets.map((snippet, i) => ( 174 <Card key={i} className="bg-muted/30"> 175 <CardContent className="p-3"> 176 <Markdown className="text-xs text-muted-foreground">{snippet}</Markdown> 177 </CardContent> 178 </Card> 179 ))} 180 </div> 181 </details> 182 )} 183 184 {onAskQuestion && ( 185 <Button variant="outline" size="sm" onClick={onAskQuestion} className="gap-1.5 w-full mt-2"> 186 <MessageCircle className="h-3.5 w-3.5" /> 187 Ask a question 188 </Button> 189 )} 190 </div> 191 </Panel> 192 193 <PanelResizeHandle className="w-1 bg-border hover:bg-primary/50 transition-colors cursor-col-resize" /> 194 195 {/* Right panel — diagram + diffs */} 196 <Panel defaultSize={60} minSize={30} className="overflow-y-auto min-h-0"> 197 <div ref={rightPanelRef} className="p-6 flex flex-col gap-4"> 198 <div className="flex items-center justify-between"> 199 {slide.mermaidDiagram && <p className="text-xs uppercase tracking-wider text-muted-foreground">Diagram</p>} 200 <div className="ml-auto"> 201 <DiffLayoutToggle value={diffLayout} onChange={onDiffLayoutChange} /> 202 </div> 203 </div> 204 205 {slide.mermaidDiagram && <MermaidDiagram chart={slide.mermaidDiagram} />} 206 207 {groupedHunks.length === 0 && ( 208 <p className="text-sm text-muted-foreground italic">No diff hunks for this slide.</p> 209 )} 210 {groupedHunks.map(({ filePath, hunks }) => { 211 if (diffLayout === 'split') { 212 return ( 213 <SplitDiffHunkGroup 214 key={filePath} 215 filePath={filePath} 216 hunks={hunks} 217 pendingComments={pendingComments} 218 slideIndex={slideNumber} 219 commentCallbacks={commentCallbacks} 220 gitFileUrlBase={gitFileUrlBase} 221 /> 222 ); 223 } 224 return commentCallbacks ? ( 225 <InteractiveDiffHunkGroup 226 key={filePath} 227 filePath={filePath} 228 hunks={hunks} 229 pendingComments={pendingComments ?? []} 230 slideIndex={slideNumber} 231 onAddComment={commentCallbacks.onAddComment} 232 onRemoveComment={commentCallbacks.onRemoveComment} 233 onEditComment={commentCallbacks.onEditComment} 234 gitFileUrlBase={gitFileUrlBase} 235 /> 236 ) : ( 237 <DiffHunkGroup key={filePath} filePath={filePath} hunks={hunks} gitFileUrlBase={gitFileUrlBase} /> 238 ); 239 })} 240 </div> 241 </Panel> 242 </PanelGroup> 243 ); 244 }