/ 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, DiffSide, 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<{ line: number; side: DiffSide } | 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({ line: lineInfo.lineNumber, side: lineInfo.side });
 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 && c.side === line.info.side
 90            );
 91            const isFormActive =
 92              activeFormLine?.line === line.info.lineNumber && activeFormLine.side === line.info.side;
 93  
 94            const diffClass = line.info.type === 'add' ? 'diff add' : line.info.type === 'remove' ? 'diff remove' : '';
 95  
 96            return (
 97              <span key={idx}>
 98                <span
 99                  className={`line ${diffClass} interactive-line`}
100                  data-file-path={filePath}
101                  data-line-number={line.info.lineNumber}
102                  style={{
103                    display: 'flex',
104                    alignItems: 'center',
105                    fontSize: '0.8125rem',
106                    lineHeight: '1.5',
107                    paddingRight: '1.25rem',
108                  }}
109                >
110                  {/* Line number gutter */}
111                  <span
112                    className="line-number-gutter"
113                    style={{
114                      display: 'inline-block',
115                      width: '3.5ch',
116                      textAlign: 'right',
117                      paddingRight: '0.5ch',
118                      color: 'var(--muted-foreground)',
119                      userSelect: 'none',
120                      flexShrink: 0,
121                      cursor: 'pointer',
122                      fontSize: '0.75rem',
123                    }}
124                    onClick={() => handleAddComment(line.info)}
125                    title={`Comment on line ${line.info.lineNumber}`}
126                  >
127                    {line.info.lineNumber}
128                  </span>
129  
130                  {/* Add comment icon (visible on hover via CSS) */}
131                  <span
132                    className="add-comment-icon"
133                    style={{
134                      display: 'inline-flex',
135                      alignItems: 'center',
136                      width: '1.25rem',
137                      flexShrink: 0,
138                      opacity: 0,
139                      cursor: 'pointer',
140                    }}
141                    onClick={() => handleAddComment(line.info)}
142                  >
143                    <MessageSquarePlus style={{ width: '0.75rem', height: '0.75rem', color: 'var(--ring)' }} />
144                  </span>
145  
146                  {/* Diff gutter character */}
147                  {hasDiff && (
148                    <span
149                      style={{
150                        display: 'inline-block',
151                        width: '1ch',
152                        marginRight: '1ch',
153                        userSelect: 'none',
154                        flexShrink: 0,
155                        color:
156                          line.info.type === 'add' ? '#3fb950' : line.info.type === 'remove' ? '#f85149' : 'transparent',
157                      }}
158                    >
159                      {line.info.type === 'add' ? '+' : line.info.type === 'remove' ? '-' : ' '}
160                    </span>
161                  )}
162  
163                  {/* Code content */}
164                  <span dangerouslySetInnerHTML={{ __html: line.html }} style={{ flex: 1, minWidth: 0 }} />
165                </span>
166  
167                {/* Comment form */}
168                {isFormActive && (
169                  <InlineCommentForm
170                    onSubmit={(body) => handleSubmitComment(body, line.info)}
171                    onCancel={() => setActiveFormLine(null)}
172                  />
173                )}
174  
175                {/* Pending comments for this line */}
176                {lineComments.map((c) => (
177                  <CommentBubble key={c.id} comment={c} onRemove={onRemoveComment} onEdit={onEditComment} />
178                ))}
179              </span>
180            );
181          })}
182        </code>
183      </pre>
184    );
185  }
186  
187  export function InteractiveDiffHunkGroup({
188    filePath,
189    hunks,
190    pendingComments,
191    slideIndex,
192    onAddComment,
193    onRemoveComment,
194    onEditComment,
195    gitFileUrlBase,
196  }: InteractiveDiffHunkGroupProps) {
197    // Count comments for this file
198    const fileCommentCount = pendingComments.filter((c) => c.filePath === filePath).length;
199  
200    return (
201      <div className="rounded-md border overflow-x-auto">
202        <div className="bg-muted/50 px-3 py-2 font-mono text-xs text-muted-foreground border-b truncate flex items-center justify-between">
203          <FilePathLink filePath={filePath} gitFileUrlBase={gitFileUrlBase} />
204          {fileCommentCount > 0 && (
205            <span className="ml-2 inline-flex items-center gap-1 text-[var(--ring)]">
206              <MessageSquarePlus className="h-3 w-3" />
207              {fileCommentCount}
208            </span>
209          )}
210        </div>
211        {hunks.map((hunk, i) => (
212          <div key={i}>
213            {i > 0 && <div className="border-t border-dashed border-muted" />}
214            {hunk.hunkHeader && (
215              <div className="bg-muted/30 px-3 py-1 font-mono text-xs text-muted-foreground border-b">
216                {hunk.hunkHeader}
217              </div>
218            )}
219            <InteractiveHunk
220              hunk={hunk}
221              filePath={filePath}
222              pendingComments={pendingComments}
223              slideIndex={slideIndex}
224              onAddComment={onAddComment}
225              onRemoveComment={onRemoveComment}
226              onEditComment={onEditComment}
227            />
228          </div>
229        ))}
230      </div>
231    );
232  }