1import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
2/**
3 * The single floating control over a pull request. Commenting on the change and submitting the
4 * review that carries the Code tab's line comments used to float as two buttons that crowded
5 * each other and read as the same offer twice; they are two modes of one composer now.
6 *
7 * Opening picks the mode with work waiting in it, so a reader who has collected line comments
8 * lands on the review and everyone else lands on the comment box. Either mode's draft survives
9 * the toggle: they are separate texts going to separate places, and merging them would send a
10 * summary as a comment or the reverse.
11 */
12import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3tools/contracts";
13import { MessageSquareIcon, Trash2Icon, XIcon } from "lucide-react";
14import { useRef, useState } from "react";
15
16import { Button } from "../ui/button";
17import { Popover, PopoverClose, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover";
18import { Toggle, ToggleGroup } from "../ui/toggle-group";
19import { PullRequestCommentForm } from "./PullRequestCommentForm";
20import { PullRequestReviewForm } from "./PullRequestReviewForm";
21import {
22 pullRequestReviewKey,
23 usePendingReviewComments,
24 usePullRequestReviewStore,
25} from "./pullRequestReviewStore";
26
27export function PullRequestComposer({
28 environmentId,
29 reference,
30 detail,
31 actionPending,
32 onCommentAction,
33 onCommented,
34 onReviewSubmitted,
35 openReviewRequest,
36}: {
37 environmentId: EnvironmentId;
38 reference: PullRequestRef;
39 detail: PullRequestDetailView;
40 actionPending: boolean;
41 onCommentAction: (
42 body: string,
43 action: "close" | "reopen",
44 ) => Promise<{ readonly commentPosted: boolean }>;
45 onCommented: () => void;
46 onReviewSubmitted: () => void;
47 /**
48 * Opens the composer on the review, each time the number changes. The guide's last stop asks
49 * for it, so finishing the tour lands on the comments it collected and the verdict.
50 */
51 openReviewRequest?: number | undefined;
52}) {
53 const [open, setOpen] = useState(false);
54 const [reviewPending, setReviewPending] = useState(false);
55 const [requestedMode, setRequestedMode] = useState<"comment" | "review">("comment");
56 const commentRef = useRef<HTMLTextAreaElement>(null);
57 const reviewRef = useRef<HTMLTextAreaElement>(null);
58 const pendingComments = usePendingReviewComments(reference);
59 const clearComments = usePullRequestReviewStore((store) => store.clear);
60 // A summary typed but not sent is review work too, and it outlives the popover. Selected as a
61 // boolean rather than the text, so typing one does not re-render the composer per keystroke.
62 const reviewKey = pullRequestReviewKey(reference);
63 const summaryStarted = usePullRequestReviewStore(
64 (store) => (store.summaries[reviewKey] ?? "").trim().length > 0,
65 );
66 const reviewStarted = pendingComments.length > 0 || summaryStarted;
67 const [answeredOpenRequest, setAnsweredOpenRequest] = useState(openReviewRequest);
68 if (openReviewRequest !== answeredOpenRequest) {
69 setAnsweredOpenRequest(openReviewRequest);
70 setRequestedMode("review");
71 setOpen(true);
72 }
73
74 // What is offered is the intersection of two different questions: what this host can do at
75 // all, and what this account may do on this repository. Either one saying no means a control
76 // that would only ever end in a refusal.
77 const canComment = detail.capabilities.comment && detail.viewerPermissions.comment;
78 const verdicts = detail.capabilities.review.verdicts.filter((verdict) =>
79 detail.viewerPermissions.verdicts.includes(verdict),
80 );
81 if (!canComment && verdicts.length === 0) return null;
82
83 const mode = canComment ? (verdicts.length === 0 ? "comment" : requestedMode) : "review";
84
85 return (
86 <Popover
87 open={open}
88 onOpenChange={(next) => {
89 if (next) setRequestedMode(reviewStarted ? "review" : "comment");
90 setOpen(next);
91 }}
92 >
93 <PopoverTrigger
94 render={<Button size="icon" variant="glass" />}
95 // The only label this control has, so the pending count belongs in it: the badge beside
96 // the icon is decorative and a reader who cannot see it still needs the number.
97 aria-label={
98 pendingComments.length > 0
99 ? `Review pull request, ${pendingComments.length} ${pendingComments.length === 1 ? "comment" : "comments"} pending`
100 : reviewStarted || !canComment
101 ? "Review pull request"
102 : "Comment on pull request"
103 }
104 >
105 <MessageSquareIcon className="size-4" />
106 {pendingComments.length > 0 ? (
107 <span
108 aria-hidden
109 className="absolute -top-1 -right-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-info px-1 text-3xs font-semibold tabular-nums text-white"
110 >
111 {pendingComments.length}
112 </span>
113 ) : null}
114 </PopoverTrigger>
115 <PopoverPopup
116 keepMounted
117 side="top"
118 align="end"
119 sideOffset={8}
120 width="lg"
121 initialFocus={mode === "review" ? reviewRef : commentRef}
122 aria-label="Pull request composer"
123 >
124 <div className="mb-3 flex items-center justify-between gap-2">
125 {canComment && verdicts.length > 0 ? (
126 <ToggleGroup
127 aria-label="Composer mode"
128 variant="segmented"
129 value={[mode]}
130 onValueChange={(next) => {
131 const value = next[0];
132 if (value === "comment" || value === "review") setRequestedMode(value);
133 }}
134 >
135 <Toggle value="comment">Comment</Toggle>
136 <Toggle value="review">
137 {pendingComments.length > 0 ? `Review (${pendingComments.length})` : "Review"}
138 </Toggle>
139 </ToggleGroup>
140 ) : (
141 <PopoverTitle>
142 {mode === "review" ? "Review pull request" : "Comment on pull request"}
143 </PopoverTitle>
144 )}
145 <div className="flex items-center gap-1">
146 {mode === "review" && pendingComments.length > 0 ? (
147 <Button
148 size="icon-xs"
149 variant="ghost"
150 aria-label="Discard pending line comments"
151 title="Discard pending line comments"
152 disabled={reviewPending}
153 onClick={() => clearComments(reviewKey)}
154 >
155 <Trash2Icon className="size-3.5" />
156 </Button>
157 ) : null}
158 <PopoverClose
159 render={<Button size="icon-xs" variant="ghost" />}
160 aria-label="Close composer"
161 >
162 <XIcon className="size-3.5" />
163 </PopoverClose>
164 </div>
165 </div>
166 {/* Keep both forms mounted across toggles and dismissal so drafts and in-flight
167 submit guards survive closing and reopening the composer. */}
168 {verdicts.length > 0 ? (
169 <div hidden={mode !== "review"}>
170 <PullRequestReviewForm
171 environmentId={environmentId}
172 reference={reference}
173 verdicts={verdicts}
174 requestChangesSummaryRequired={sourceControlClients
175 .get(detail.provider)
176 .reviewSummaryRequired("request-changes")}
177 textareaRef={reviewRef}
178 pending={reviewPending}
179 onPendingChange={setReviewPending}
180 onSubmitted={() => {
181 setOpen(false);
182 onReviewSubmitted();
183 }}
184 />
185 </div>
186 ) : null}
187 {canComment ? (
188 <div hidden={mode !== "comment"}>
189 <PullRequestCommentForm
190 environmentId={environmentId}
191 reference={reference}
192 detail={detail}
193 actionPending={actionPending}
194 textareaRef={commentRef}
195 onCommentAction={onCommentAction}
196 onCommented={onCommented}
197 onClose={() => setOpen(false)}
198 />
199 </div>
200 ) : null}
201 </PopoverPopup>
202 </Popover>
203 );
204}