/ 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">&#x25B6;</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  }