/ components / SplitDiffHunk.tsx
SplitDiffHunk.tsx
  1  import { useState, useMemo } from 'react';
  2  import { MessageSquarePlus } from 'lucide-react';
  3  import { parseDiffLines, buildSplitRows, type DiffLineInfo, type SplitRow } 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 SplitDiffHunkGroupProps {
 15    filePath: string;
 16    hunks: DiffHunk[];
 17    pendingComments?: PendingReviewComment[];
 18    slideIndex?: number;
 19    commentCallbacks?: CommentCallbacks;
 20    gitFileUrlBase?: string | null;
 21  }
 22  
 23  function SplitDiffCell({
 24    info,
 25    html,
 26    cellClass,
 27    isInteractive,
 28    onClickLine,
 29  }: {
 30    info: DiffLineInfo | null;
 31    html: string | null;
 32    cellClass: string;
 33    isInteractive: boolean;
 34    onClickLine: (info: DiffLineInfo) => void;
 35  }) {
 36    const lineNum =
 37      info?.type === 'context' ? info.lineNumber : info?.type === 'remove' ? info.baseLineNumber : info?.headLineNumber;
 38  
 39    return (
 40      <>
 41        {isInteractive && (
 42          <span
 43            className={`split-diff-comment-icon ${cellClass}`}
 44            style={{
 45              display: 'flex',
 46              alignItems: 'center',
 47              justifyContent: 'center',
 48              cursor: info ? 'pointer' : 'default',
 49            }}
 50            onClick={() => info && onClickLine(info)}
 51          >
 52            {info && (
 53              <MessageSquarePlus
 54                style={{ width: '0.75rem', height: '0.75rem', color: '#58a6ff', opacity: 0 }}
 55                className="split-icon-hover"
 56              />
 57            )}
 58          </span>
 59        )}
 60        <span
 61          className={`split-diff-line-num ${cellClass}`}
 62          style={{
 63            textAlign: 'right',
 64            paddingRight: '0.5ch',
 65            color: 'rgba(255,255,255,0.3)',
 66            userSelect: 'none',
 67            fontSize: '0.75rem',
 68            cursor: isInteractive && info ? 'pointer' : 'default',
 69          }}
 70          onClick={() => isInteractive && info && onClickLine(info)}
 71        >
 72          {lineNum ?? ''}
 73        </span>
 74        <span
 75          className={`split-diff-code select-text ${cellClass}`}
 76          style={{ paddingLeft: '0.5ch', paddingRight: '1ch', whiteSpace: 'pre' }}
 77        >
 78          {html ? <span dangerouslySetInnerHTML={{ __html: html }} /> : null}
 79        </span>
 80      </>
 81    );
 82  }
 83  
 84  function SplitHunk({
 85    hunk,
 86    filePath,
 87    pendingComments,
 88    slideIndex,
 89    commentCallbacks,
 90  }: {
 91    hunk: DiffHunk;
 92    filePath: string;
 93    pendingComments: PendingReviewComment[];
 94    slideIndex: number;
 95    commentCallbacks?: CommentCallbacks;
 96  }) {
 97    const [activeFormKey, setActiveFormKey] = useState<string | null>(null);
 98  
 99    const lineInfos = useMemo(() => parseDiffLines(hunk.hunkHeader, hunk.content), [hunk.hunkHeader, hunk.content]);
100    const lineHtmls = useMemo(() => parseShikiLines(hunk.renderedHtml), [hunk.renderedHtml]);
101    const shikiStyles = useMemo(() => extractShikiStyles(hunk.renderedHtml), [hunk.renderedHtml]);
102  
103    const splitRows = useMemo(() => {
104      if (!lineHtmls || lineInfos.length === 0 || lineHtmls.length < lineInfos.length) return null;
105      return buildSplitRows(lineInfos, lineHtmls);
106    }, [lineInfos, lineHtmls]);
107  
108    if (!splitRows) {
109      return <div className="select-text" dangerouslySetInnerHTML={{ __html: hunk.renderedHtml }} />;
110    }
111  
112    const isInteractive = !!commentCallbacks;
113  
114    function handleAddComment(info: DiffLineInfo) {
115      setActiveFormKey(`${info.lineNumber}:${info.side}`);
116    }
117  
118    function handleSubmitComment(body: string, info: DiffLineInfo) {
119      commentCallbacks?.onAddComment({
120        filePath,
121        line: info.lineNumber,
122        side: info.side,
123        body,
124        hunkHeader: hunk.hunkHeader,
125        codeSnippet: info.text,
126        slideIndex,
127      });
128      setActiveFormKey(null);
129    }
130  
131    const gridTemplateColumns = isInteractive ? '1.25rem 3.5ch 1fr 1px 1.25rem 3.5ch 1fr' : '3.5ch 1fr 1px 3.5ch 1fr';
132    const gridColumnCount = isInteractive ? 7 : 5;
133  
134    return (
135      <div
136        className={shikiStyles.preClass}
137        style={{
138          ...shikiStyles.preStyle,
139          overflow: 'auto',
140          fontFamily: 'var(--font-mono)',
141          fontSize: '0.8125rem',
142          lineHeight: '1.5',
143        }}
144      >
145        <div
146          className="split-diff-grid"
147          style={{ display: 'grid', gridTemplateColumns, minWidth: '100%', width: 'max-content' }}
148        >
149          {splitRows.map((row, rowIdx) => {
150            const leftInfo = row.left?.info ?? null;
151            const rightInfo = row.right?.info ?? null;
152  
153            const leftCellClass =
154              leftInfo?.type === 'remove' ? 'split-diff-cell-remove' : !row.left ? 'split-diff-cell-empty' : '';
155            const rightCellClass =
156              rightInfo?.type === 'add' ? 'split-diff-cell-add' : !row.right ? 'split-diff-cell-empty' : '';
157  
158            const leftFormKey = leftInfo ? `${leftInfo.lineNumber}:${leftInfo.side}` : null;
159            const rightFormKey = rightInfo ? `${rightInfo.lineNumber}:${rightInfo.side}` : null;
160            const showLeftForm = isInteractive && activeFormKey === leftFormKey;
161            const showRightForm = isInteractive && activeFormKey === rightFormKey;
162  
163            const leftComments =
164              isInteractive && leftInfo
165                ? pendingComments.filter(
166                    (c) => c.line === leftInfo.lineNumber && c.side === leftInfo.side && c.filePath === filePath
167                  )
168                : [];
169            const rightComments =
170              isInteractive && rightInfo
171                ? pendingComments.filter(
172                    (c) => c.line === rightInfo.lineNumber && c.side === rightInfo.side && c.filePath === filePath
173                  )
174                : [];
175  
176            const fullSpan = { gridColumn: `1 / ${gridColumnCount + 1}` };
177  
178            return (
179              <SplitDiffRowFragment
180                key={rowIdx}
181                row={row}
182                leftCellClass={leftCellClass}
183                rightCellClass={rightCellClass}
184                isInteractive={isInteractive}
185                onClickLine={handleAddComment}
186                showLeftForm={showLeftForm}
187                showRightForm={showRightForm}
188                leftInfo={leftInfo}
189                rightInfo={rightInfo}
190                leftComments={leftComments}
191                rightComments={rightComments}
192                fullSpan={fullSpan}
193                onSubmitComment={handleSubmitComment}
194                onCancelForm={() => setActiveFormKey(null)}
195                commentCallbacks={commentCallbacks}
196              />
197            );
198          })}
199        </div>
200      </div>
201    );
202  }
203  
204  function SplitDiffRowFragment({
205    row,
206    leftCellClass,
207    rightCellClass,
208    isInteractive,
209    onClickLine,
210    showLeftForm,
211    showRightForm,
212    leftInfo,
213    rightInfo,
214    leftComments,
215    rightComments,
216    fullSpan,
217    onSubmitComment,
218    onCancelForm,
219    commentCallbacks,
220  }: {
221    row: SplitRow;
222    leftCellClass: string;
223    rightCellClass: string;
224    isInteractive: boolean;
225    onClickLine: (info: DiffLineInfo) => void;
226    showLeftForm: boolean | string | null;
227    showRightForm: boolean | string | null;
228    leftInfo: DiffLineInfo | null;
229    rightInfo: DiffLineInfo | null;
230    leftComments: PendingReviewComment[];
231    rightComments: PendingReviewComment[];
232    fullSpan: React.CSSProperties;
233    onSubmitComment: (body: string, info: DiffLineInfo) => void;
234    onCancelForm: () => void;
235    commentCallbacks?: CommentCallbacks;
236  }) {
237    return (
238      <>
239        <SplitDiffCell
240          info={row.left?.info ?? null}
241          html={row.left?.html ?? null}
242          cellClass={leftCellClass}
243          isInteractive={isInteractive}
244          onClickLine={onClickLine}
245        />
246  
247        <span className="split-diff-separator" />
248  
249        <SplitDiffCell
250          info={row.right?.info ?? null}
251          html={row.right?.html ?? null}
252          cellClass={rightCellClass}
253          isInteractive={isInteractive}
254          onClickLine={onClickLine}
255        />
256  
257        {showLeftForm && leftInfo && (
258          <div style={fullSpan}>
259            <InlineCommentForm onSubmit={(body) => onSubmitComment(body, leftInfo)} onCancel={onCancelForm} />
260          </div>
261        )}
262        {commentCallbacks &&
263          leftComments.map((c) => (
264            <div key={c.id} style={fullSpan}>
265              <CommentBubble
266                comment={c}
267                onRemove={commentCallbacks.onRemoveComment}
268                onEdit={commentCallbacks.onEditComment}
269              />
270            </div>
271          ))}
272        {showRightForm && rightInfo && (
273          <div style={fullSpan}>
274            <InlineCommentForm onSubmit={(body) => onSubmitComment(body, rightInfo)} onCancel={onCancelForm} />
275          </div>
276        )}
277        {commentCallbacks &&
278          rightComments.map((c) => (
279            <div key={c.id} style={fullSpan}>
280              <CommentBubble
281                comment={c}
282                onRemove={commentCallbacks.onRemoveComment}
283                onEdit={commentCallbacks.onEditComment}
284              />
285            </div>
286          ))}
287      </>
288    );
289  }
290  
291  export function SplitDiffHunkGroup({
292    filePath,
293    hunks,
294    pendingComments = [],
295    slideIndex = 0,
296    commentCallbacks,
297    gitFileUrlBase,
298  }: SplitDiffHunkGroupProps) {
299    const fileCommentCount = pendingComments.filter((c) => c.filePath === filePath).length;
300  
301    return (
302      <div className="rounded-md border overflow-x-auto">
303        <div className="bg-muted/50 px-3 py-2 font-mono text-xs text-muted-foreground border-b truncate flex items-center justify-between">
304          <FilePathLink filePath={filePath} gitFileUrlBase={gitFileUrlBase} />
305          {commentCallbacks && fileCommentCount > 0 && (
306            <span className="ml-2 inline-flex items-center gap-1 text-blue-400">
307              <MessageSquarePlus className="h-3 w-3" />
308              {fileCommentCount}
309            </span>
310          )}
311        </div>
312        {hunks.map((hunk, i) => (
313          <div key={i}>
314            {i > 0 && <div className="border-t border-dashed border-muted" />}
315            {hunk.hunkHeader && (
316              <div className="bg-muted/30 px-3 py-1 font-mono text-xs text-muted-foreground border-b">
317                {hunk.hunkHeader}
318              </div>
319            )}
320            <SplitHunk
321              hunk={hunk}
322              filePath={filePath}
323              pendingComments={pendingComments}
324              slideIndex={slideIndex}
325              commentCallbacks={commentCallbacks}
326            />
327          </div>
328        ))}
329      </div>
330    );
331  }