/ components / shared-diff-utils.tsx
shared-diff-utils.tsx
  1  import { useState, useRef, useEffect } from 'react';
  2  import { Pencil, Trash2 } from 'lucide-react';
  3  import { Button } from '@/components/ui/button';
  4  import type { PendingReviewComment, DiffSide } from '@/lib/types';
  5  
  6  export interface CommentCallbacks {
  7    onAddComment: (params: {
  8      filePath: string;
  9      line: number;
 10      side: DiffSide;
 11      body: string;
 12      hunkHeader: string;
 13      codeSnippet: string;
 14      slideIndex: number;
 15    }) => void;
 16    onRemoveComment: (id: string) => void;
 17    onEditComment: (id: string, body: string) => void;
 18  }
 19  
 20  /**
 21   * Parse the Shiki-rendered HTML to extract individual line elements.
 22   * Shiki wraps each line in <span class="line">...</span> inside <code> inside <pre>.
 23   */
 24  export function parseShikiLines(renderedHtml: string): string[] | null {
 25    try {
 26      const parser = new DOMParser();
 27      const doc = parser.parseFromString(renderedHtml, 'text/html');
 28      const lines = doc.querySelectorAll('span.line');
 29      if (lines.length === 0) return null;
 30      return Array.from(lines).map((el) => el.innerHTML);
 31    } catch {
 32      return null;
 33    }
 34  }
 35  
 36  /**
 37   * Extract the Shiki theme styles from the rendered <pre> element so we can
 38   * re-apply them when rendering individual lines outside the original tree.
 39   */
 40  export function extractShikiStyles(renderedHtml: string): { preStyle: Record<string, string>; preClass: string } {
 41    try {
 42      const parser = new DOMParser();
 43      const doc = parser.parseFromString(renderedHtml, 'text/html');
 44      const pre = doc.querySelector('pre');
 45      const styleStr = pre?.getAttribute('style') ?? '';
 46      const preStyle: Record<string, string> = {};
 47      for (const decl of styleStr.split(';')) {
 48        const [prop, ...rest] = decl.split(':');
 49        if (!prop.trim() || rest.length === 0) continue;
 50        const camel = prop.trim().replace(/-([a-z])/g, (_match, c: string) => c.toUpperCase());
 51        preStyle[camel] = rest.join(':').trim();
 52      }
 53      return {
 54        preStyle,
 55        preClass: pre?.getAttribute('class') ?? '',
 56      };
 57    } catch {
 58      return { preStyle: {}, preClass: '' };
 59    }
 60  }
 61  
 62  export function InlineCommentForm({
 63    onSubmit,
 64    onCancel,
 65    initialBody,
 66  }: {
 67    onSubmit: (body: string) => void;
 68    onCancel: () => void;
 69    initialBody?: string;
 70  }) {
 71    const [body, setBody] = useState(initialBody ?? '');
 72    const textareaRef = useRef<HTMLTextAreaElement>(null);
 73  
 74    useEffect(() => {
 75      textareaRef.current?.focus();
 76    }, []);
 77  
 78    function handleKeyDown(e: React.KeyboardEvent) {
 79      if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
 80        e.preventDefault();
 81        if (body.trim()) onSubmit(body.trim());
 82      }
 83      if (e.key === 'Escape') {
 84        e.preventDefault();
 85        onCancel();
 86      }
 87    }
 88  
 89    return (
 90      <div className="mx-2 my-1.5 border rounded-md bg-muted/30 p-2">
 91        <textarea
 92          ref={textareaRef}
 93          value={body}
 94          onChange={(e) => setBody(e.target.value)}
 95          onKeyDown={handleKeyDown}
 96          placeholder="Leave a comment... (Cmd+Enter to add, Esc to cancel)"
 97          className="w-full min-h-[60px] bg-transparent text-sm font-mono resize-y border rounded p-2 focus:outline-none focus:ring-1 focus:ring-ring"
 98        />
 99        <div className="flex justify-end gap-1.5 mt-1.5">
100          <Button variant="ghost" size="sm" onClick={onCancel}>
101            Cancel
102          </Button>
103          <Button size="sm" onClick={() => body.trim() && onSubmit(body.trim())} disabled={!body.trim()}>
104            Add comment
105          </Button>
106        </div>
107      </div>
108    );
109  }
110  
111  export function CommentBubble({
112    comment,
113    onRemove,
114    onEdit,
115  }: {
116    comment: PendingReviewComment;
117    onRemove: (id: string) => void;
118    onEdit: (id: string, body: string) => void;
119  }) {
120    const [editing, setEditing] = useState(false);
121  
122    if (editing) {
123      return (
124        <InlineCommentForm
125          initialBody={comment.body}
126          onSubmit={(body) => {
127            onEdit(comment.id, body);
128            setEditing(false);
129          }}
130          onCancel={() => setEditing(false)}
131        />
132      );
133    }
134  
135    return (
136      <div className="mx-2 my-1 border rounded-md bg-blue-950/30 border-blue-800/30 p-2 flex gap-2 group">
137        <pre className="flex-1 text-xs font-mono whitespace-pre-wrap break-words text-blue-200">{comment.body}</pre>
138        <div className="flex flex-col gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
139          <button
140            onClick={() => setEditing(true)}
141            className="p-0.5 rounded hover:bg-muted/50 text-muted-foreground hover:text-foreground"
142            title="Edit"
143          >
144            <Pencil className="h-3 w-3" />
145          </button>
146          <button
147            onClick={() => onRemove(comment.id)}
148            className="p-0.5 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive"
149            title="Delete"
150          >
151            <Trash2 className="h-3 w-3" />
152          </button>
153        </div>
154      </div>
155    );
156  }