/ components / InteractiveDiffHunk.tsx
InteractiveDiffHunk.tsx
  1  import { useState, useMemo } from 'react';
  2  import { MessageSquarePlus } from 'lucide-react';
  3  import { parseDiffLines, type DiffLineInfo } 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 InteractiveDiffHunkGroupProps extends CommentCallbacks {
 15    filePath: string;
 16    hunks: DiffHunk[];
 17    pendingComments: PendingReviewComment[];
 18    slideIndex: number;
 19    gitFileUrlBase?: string | null;
 20  }
 21  
 22  interface ParsedLine {
 23    info: DiffLineInfo;
 24    html: string;
 25  }
 26  
 27  function InteractiveHunk({
 28    hunk,
 29    filePath,
 30    pendingComments,
 31    slideIndex,
 32    onAddComment,
 33    onRemoveComment,
 34    onEditComment,
 35  }: {
 36    hunk: DiffHunk;
 37    filePath: string;
 38    pendingComments: PendingReviewComment[];
 39    slideIndex: number;
 40  } & CommentCallbacks) {
 41    const [activeFormLine, setActiveFormLine] = useState<number | null>(null);
 42  
 43    const lineInfos = useMemo(() => parseDiffLines(hunk.hunkHeader, hunk.content), [hunk.hunkHeader, hunk.content]);
 44  
 45    const lineHtmls = useMemo(() => parseShikiLines(hunk.renderedHtml), [hunk.renderedHtml]);
 46    const shikiStyles = useMemo(() => extractShikiStyles(hunk.renderedHtml), [hunk.renderedHtml]);
 47  
 48    // Fall back to non-interactive rendering if we can't align parsed lines with HTML.
 49    // Allow lineHtmls to have trailing extra elements (Shiki may add a trailing empty line span).
 50    if (!lineHtmls || lineInfos.length === 0 || lineHtmls.length < lineInfos.length) {
 51      if (lineHtmls && lineInfos.length > 0) {
 52        console.warn(
 53          `[InteractiveDiffHunk] Line count mismatch for ${filePath}: ` +
 54            `parsed=${lineInfos.length} html=${lineHtmls.length}, falling back to non-interactive`
 55        );
 56      }
 57      return <div className="select-text" dangerouslySetInnerHTML={{ __html: hunk.renderedHtml }} />;
 58    }
 59  
 60    const lines: ParsedLine[] = lineInfos.map((info, i) => ({
 61      info,
 62      html: lineHtmls[i],
 63    }));
 64  
 65    function handleAddComment(lineInfo: DiffLineInfo) {
 66      setActiveFormLine(lineInfo.lineNumber);
 67    }
 68  
 69    function handleSubmitComment(body: string, lineInfo: DiffLineInfo) {
 70      onAddComment({
 71        filePath,
 72        line: lineInfo.lineNumber,
 73        side: lineInfo.side,
 74        body,
 75        hunkHeader: hunk.hunkHeader,
 76        codeSnippet: lineInfo.text,
 77        slideIndex,
 78      });
 79      setActiveFormLine(null);
 80    }
 81  
 82    const hasDiff = lineInfos.some((l) => l.type !== 'context');
 83  
 84    return (
 85      <pre className={`${shikiStyles.preClass} select-text`} style={shikiStyles.preStyle}>
 86        <code style={{ display: 'block', fontSize: 0, minWidth: '100%', width: 'max-content' }}>
 87          {lines.map((line, idx) => {
 88            const lineComments = pendingComments.filter(
 89              (c) => c.line === line.info.lineNumber && c.filePath === filePath
 90            );
 91            const isFormActive = activeFormLine === line.info.lineNumber;
 92  
 93            const diffClass = line.info.type === 'add' ? 'diff add' : line.info.type === 'remove' ? 'diff remove' : '';
 94  
 95            return (
 96              <span key={idx}>
 97                <span
 98                  className={`line ${diffClass} interactive-line`}
 99                  data-file-path={filePath}
100                  data-line-number={line.info.lineNumber}
101                  style={{
102                    display: 'flex',
103                    alignItems: 'center',
104                    fontSize: '0.8125rem',
105                    lineHeight: '1.5',
106                    paddingRight: '1.25rem',
107                  }}
108                >
109                  {/* Line number gutter */}
110                  <span
111                    className="line-number-gutter"
112                    style={{
113                      display: 'inline-block',
114                      width: '3.5ch',
115                      textAlign: 'right',
116                      paddingRight: '0.5ch',
117                      color: 'rgba(255,255,255,0.3)',
118                      userSelect: 'none',
119                      flexShrink: 0,
120                      cursor: 'pointer',
121                      fontSize: '0.75rem',
122                    }}
123                    onClick={() => handleAddComment(line.info)}
124                    title={`Comment on line ${line.info.lineNumber}`}
125                  >
126                    {line.info.lineNumber}
127                  </span>
128  
129                  {/* Add comment icon (visible on hover via CSS) */}
130                  <span
131                    className="add-comment-icon"
132                    style={{
133                      display: 'inline-flex',
134                      alignItems: 'center',
135                      width: '1.25rem',
136                      flexShrink: 0,
137                      opacity: 0,
138                      cursor: 'pointer',
139                    }}
140                    onClick={() => handleAddComment(line.info)}
141                  >
142                    <MessageSquarePlus style={{ width: '0.75rem', height: '0.75rem', color: '#58a6ff' }} />
143                  </span>
144  
145                  {/* Diff gutter character */}
146                  {hasDiff && (
147                    <span
148                      style={{
149                        display: 'inline-block',
150                        width: '1ch',
151                        marginRight: '1ch',
152                        userSelect: 'none',
153                        flexShrink: 0,
154                        color:
155                          line.info.type === 'add' ? '#3fb950' : line.info.type === 'remove' ? '#f85149' : 'transparent',
156                      }}
157                    >
158                      {line.info.type === 'add' ? '+' : line.info.type === 'remove' ? '-' : ' '}
159                    </span>
160                  )}
161  
162                  {/* Code content */}
163                  <span dangerouslySetInnerHTML={{ __html: line.html }} style={{ flex: 1, minWidth: 0 }} />
164                </span>
165  
166                {/* Comment form */}
167                {isFormActive && (
168                  <InlineCommentForm
169                    onSubmit={(body) => handleSubmitComment(body, line.info)}
170                    onCancel={() => setActiveFormLine(null)}
171                  />
172                )}
173  
174                {/* Pending comments for this line */}
175                {lineComments.map((c) => (
176                  <CommentBubble key={c.id} comment={c} onRemove={onRemoveComment} onEdit={onEditComment} />
177                ))}
178              </span>
179            );
180          })}
181        </code>
182      </pre>
183    );
184  }
185  
186  export function InteractiveDiffHunkGroup({
187    filePath,
188    hunks,
189    pendingComments,
190    slideIndex,
191    onAddComment,
192    onRemoveComment,
193    onEditComment,
194    gitFileUrlBase,
195  }: InteractiveDiffHunkGroupProps) {
196    // Count comments for this file
197    const fileCommentCount = pendingComments.filter((c) => c.filePath === filePath).length;
198  
199    return (
200      <div className="rounded-md border overflow-x-auto">
201        <div className="bg-muted/50 px-3 py-2 font-mono text-xs text-muted-foreground border-b truncate flex items-center justify-between">
202          <FilePathLink filePath={filePath} gitFileUrlBase={gitFileUrlBase} />
203          {fileCommentCount > 0 && (
204            <span className="ml-2 inline-flex items-center gap-1 text-blue-400">
205              <MessageSquarePlus className="h-3 w-3" />
206              {fileCommentCount}
207            </span>
208          )}
209        </div>
210        {hunks.map((hunk, i) => (
211          <div key={i}>
212            {i > 0 && <div className="border-t border-dashed border-muted" />}
213            {hunk.hunkHeader && (
214              <div className="bg-muted/30 px-3 py-1 font-mono text-xs text-muted-foreground border-b">
215                {hunk.hunkHeader}
216              </div>
217            )}
218            <InteractiveHunk
219              hunk={hunk}
220              filePath={filePath}
221              pendingComments={pendingComments}
222              slideIndex={slideIndex}
223              onAddComment={onAddComment}
224              onRemoveComment={onRemoveComment}
225              onEditComment={onEditComment}
226            />
227          </div>
228        ))}
229      </div>
230    );
231  }