| 1 | import { MessageCircle, Trash2 } from "lucide-react"; |
| 2 | import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; |
| 3 | |
| 4 | import { Button } from "~/components/ui/button"; |
| 5 | import { Textarea } from "~/components/ui/textarea"; |
| 6 | |
| 7 | import { isCommentSubmitShortcut } from "./commentSubmitShortcut"; |
| 8 | |
| 9 | interface DiffCommentSecondaryAction { |
| 10 | readonly label: string; |
| 11 | readonly icon?: ReactNode; |
| 12 | readonly allowEmpty?: boolean; |
| 13 | readonly onAction: (text: string) => void; |
| 14 | } |
| 15 | |
| 16 | interface DiffCommentAnnotationProps { |
| 17 | kind: "draft" | "comment"; |
| 18 | rangeLabel: string; |
| 19 | text: string; |
| 20 | onTextChange?: (text: string) => void; |
| 21 | onCancel: () => void; |
| 22 | onComment: (text: string) => void; |
| 23 | onDelete?: () => void; |
| 24 | placeholder?: string; |
| 25 | submitLabel?: string; |
| 26 | pending?: boolean; |
| 27 | secondaryAction?: DiffCommentSecondaryAction; |
| 28 | focusOnMount?: boolean; |
| 29 | } |
| 30 | |
| 31 | /** The shared inline comment treatment for file previews, thread diffs, and pull-request diffs. */ |
| 32 | export function DiffCommentAnnotation({ |
| 33 | kind, |
| 34 | rangeLabel, |
| 35 | text, |
| 36 | onTextChange, |
| 37 | onCancel, |
| 38 | onComment, |
| 39 | onDelete, |
| 40 | placeholder = "Add a comment…", |
| 41 | submitLabel = "Comment", |
| 42 | pending = false, |
| 43 | secondaryAction, |
| 44 | focusOnMount = true, |
| 45 | }: DiffCommentAnnotationProps) { |
| 46 | const [localDraftText, setLocalDraftText] = useState(""); |
| 47 | const displayedText = kind === "draft" && !onTextChange ? localDraftText : text; |
| 48 | const trimmedText = displayedText.trim(); |
| 49 | const textareaRef = useRef<HTMLTextAreaElement | null>(null); |
| 50 | |
| 51 | useLayoutEffect(() => { |
| 52 | if (kind !== "draft" || !focusOnMount) return; |
| 53 | const frame = window.requestAnimationFrame(() => { |
| 54 | textareaRef.current?.focus({ preventScroll: true }); |
| 55 | }); |
| 56 | return () => window.cancelAnimationFrame(frame); |
| 57 | }, [focusOnMount, kind]); |
| 58 | |
| 59 | if (kind === "comment") { |
| 60 | return ( |
| 61 | <div |
| 62 | data-diff-comment-annotation |
| 63 | className="group/comment flex min-w-0 items-start gap-2.5 border-s-2 border-primary/55 bg-primary/[0.045] px-3 py-2.5 font-sans text-foreground" |
| 64 | contentEditable={false} |
| 65 | onPointerDown={(event) => event.stopPropagation()} |
| 66 | > |
| 67 | <MessageCircle className="mt-0.5 size-3.5 shrink-0 text-primary/70" aria-hidden="true" /> |
| 68 | <p className="min-w-0 flex-1 whitespace-pre-wrap text-sm leading-5">{displayedText}</p> |
| 69 | {onDelete ? ( |
| 70 | <span className="-my-1 -mr-1 flex shrink-0 opacity-0 transition-opacity group-hover/comment:opacity-100 focus-within:opacity-100 max-sm:opacity-100"> |
| 71 | <Button |
| 72 | variant="ghost-muted" |
| 73 | size="icon-xs" |
| 74 | aria-label="Delete comment" |
| 75 | onClick={onDelete} |
| 76 | > |
| 77 | <Trash2 className="size-3" /> |
| 78 | </Button> |
| 79 | </span> |
| 80 | ) : null} |
| 81 | </div> |
| 82 | ); |
| 83 | } |
| 84 | |
| 85 | return ( |
| 86 | <div |
| 87 | data-diff-comment-annotation |
| 88 | className="px-3 py-2 font-sans text-foreground" |
| 89 | contentEditable={false} |
| 90 | onPointerDown={(event) => event.stopPropagation()} |
| 91 | > |
| 92 | <Textarea |
| 93 | ref={textareaRef} |
| 94 | autoFocus={focusOnMount} |
| 95 | size="sm" |
| 96 | value={displayedText} |
| 97 | placeholder={placeholder} |
| 98 | aria-label={`Comment on lines ${rangeLabel}`} |
| 99 | onChange={(event) => (onTextChange ?? setLocalDraftText)(event.target.value)} |
| 100 | onFocus={(event) => { |
| 101 | const end = event.currentTarget.value.length; |
| 102 | event.currentTarget.setSelectionRange(end, end); |
| 103 | }} |
| 104 | onKeyDown={(event) => { |
| 105 | if (event.key === "Escape") { |
| 106 | event.preventDefault(); |
| 107 | onCancel(); |
| 108 | } |
| 109 | if (isCommentSubmitShortcut(event, trimmedText, pending)) { |
| 110 | event.preventDefault(); |
| 111 | onComment(trimmedText); |
| 112 | } |
| 113 | }} |
| 114 | /> |
| 115 | <div className="mt-1.5 flex items-center gap-1"> |
| 116 | <span className="mr-auto text-3xs text-muted-foreground/70">⌘/Ctrl Enter to send</span> |
| 117 | <Button variant="ghost-muted" size="xs" onClick={onCancel}> |
| 118 | Cancel |
| 119 | </Button> |
| 120 | {secondaryAction ? ( |
| 121 | <Button |
| 122 | size="xs" |
| 123 | variant="outline" |
| 124 | disabled={!secondaryAction.allowEmpty && !trimmedText} |
| 125 | onClick={() => secondaryAction.onAction(trimmedText)} |
| 126 | > |
| 127 | {secondaryAction.icon} |
| 128 | {secondaryAction.label} |
| 129 | </Button> |
| 130 | ) : null} |
| 131 | <Button size="xs" disabled={pending || !trimmedText} onClick={() => onComment(trimmedText)}> |
| 132 | {submitLabel} |
| 133 | </Button> |
| 134 | </div> |
| 135 | </div> |
| 136 | ); |
| 137 | } |