1import { MessageCircle, Trash2 } from "lucide-react";
2import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
3
4import { Button } from "~/components/ui/button";
5import { Textarea } from "~/components/ui/textarea";
6
7import { isCommentSubmitShortcut } from "./commentSubmitShortcut";
8
9interface DiffCommentSecondaryAction {
10 readonly label: string;
11 readonly icon?: ReactNode;
12 readonly allowEmpty?: boolean;
13 readonly onAction: (text: string) => void;
14}
15
16interface 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. */
32export 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}