| author | |
| committer | |
| log | 5423ba0fd08447240282c08362ee8e382bf16903 |
| tree | e103240fa0ffc063151e985b25e0f212acdc45cc |
| parent | 9a609a4e444ba739d6fcd607769d68b679b0bcc5 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <maria@kuuro.net>8 files changed, 534 insertions(+), 422 deletions(-)
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+6-84| ... | ... | @@ -18,13 +18,11 @@ import { |
| 18 | 18 | Columns2Icon, |
| 19 | 19 | FolderTreeIcon, |
| 20 | 20 | InfoIcon, |
| 21 | MessageSquareIcon, | |
| 22 | 21 | MessageSquareOffIcon, |
| 23 | 22 | PilcrowIcon, |
| 24 | 23 | Rows3Icon, |
| 25 | 24 | TextWrapIcon, |
| 26 | 25 | TriangleAlertIcon, |
| 27 | XIcon, | |
| 28 | 26 | } from "lucide-react"; |
| 29 | 27 | import { useAtomRefresh } from "@effect/atom-react"; |
| 30 | 28 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -78,7 +76,6 @@ import { toastManager } from "../ui/toast"; |
| 78 | 76 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; |
| 79 | 77 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 80 | 78 | import { PendingReviewCommentCard, ReviewThreadCard } from "./PullRequestReviewAnnotation"; |
| 81 | import { PullRequestReviewBar } from "./PullRequestReviewBar"; | |
| 82 | 79 | import { |
| 83 | 80 | isFileDiffCollapsed, |
| 84 | 81 | isLineInFileDiff, |
| ... | ... | @@ -247,9 +244,6 @@ function PullRequestCodeTab({ |
| 247 | 244 | const [draft, setDraft] = useState<DraftAnchor | null>(null); |
| 248 | 245 | const [threadPending, setThreadPending] = useState(false); |
| 249 | 246 | const [orphansOpen, setOrphansOpen] = useState(false); |
| 250 | // Closed by default so the review form does not permanently eat vertical space below the | |
| 251 | // diff; opened on demand as a floating overlay instead. | |
| 252 | const [reviewOpen, setReviewOpen] = useState(false); | |
| 253 | 247 | // Which pull request the slices belong to travels with them, so a render taken before the |
| 254 | 248 | // reset below cannot read the previous one's slices — or send its cursor to the host. |
| 255 | 249 | const [sliceState, setSliceState] = useState<{ |
| ... | ... | @@ -378,7 +372,6 @@ function PullRequestCodeTab({ |
| 378 | 372 | inlineComment: hostReview.inlineComment && viewer.comment, |
| 379 | 373 | reply: hostReview.reply && viewer.comment, |
| 380 | 374 | resolve: hostReview.resolve && viewer.resolve, |
| 381 | verdicts: hostReview.verdicts.filter((verdict) => viewer.verdicts.includes(verdict)), | |
| 382 | 375 | }; |
| 383 | 376 | }, [detail.capabilities.review, detail.viewerPermissions]); |
| 384 | 377 | // A comment is posted against the pull request's head diff, so a line number taken from one |
| ... | ... | @@ -1054,70 +1047,6 @@ function PullRequestCodeTab({ |
| 1054 | 1047 | ], |
| 1055 | 1048 | ); |
| 1056 | 1049 | |
| 1057 | /** | |
| 1058 | * The review overlay belongs to the pull request, not to the patch: a change whose diff | |
| 1059 | * cannot be structured — or read at all — is still one a reviewer can approve or reject, so | |
| 1060 | * it survives every branch below. It floats over the scroll area rather than sitting in the | |
| 1061 | * layout flow, so the diff keeps the full height instead of permanently losing a strip to a | |
| 1062 | * footer most reviews never touch. Hidden entirely where the host offers no verdicts, same as | |
| 1063 | * the bar it wraps did. | |
| 1064 | */ | |
| 1065 | const reviewOverlay = | |
| 1066 | review.verdicts.length === 0 ? null : ( | |
| 1067 | <div className="pointer-events-none absolute inset-x-0 bottom-0 z-10"> | |
| 1068 | {reviewOpen ? ( | |
| 1069 | <div | |
| 1070 | className={cn( | |
| 1071 | "surface-glass pointer-events-auto absolute inset-x-3 rounded-xl border border-border/60 shadow-lg", | |
| 1072 | detail.capabilities.comment && detail.viewerPermissions.comment | |
| 1073 | ? "bottom-16" | |
| 1074 | : "bottom-3", | |
| 1075 | )} | |
| 1076 | > | |
| 1077 | <Button | |
| 1078 | type="button" | |
| 1079 | size="icon-sm" | |
| 1080 | variant="ghost" | |
| 1081 | aria-label="Close review" | |
| 1082 | className="absolute right-2 top-2" | |
| 1083 | onClick={() => setReviewOpen(false)} | |
| 1084 | > | |
| 1085 | <XIcon className="size-3.5" /> | |
| 1086 | </Button> | |
| 1087 | <PullRequestReviewBar | |
| 1088 | environmentId={environmentId} | |
| 1089 | reference={reference} | |
| 1090 | verdicts={review.verdicts} | |
| 1091 | requestChangesSummaryRequired={detail.provider === "forgejo"} | |
| 1092 | onSubmitted={() => { | |
| 1093 | onRefresh(); | |
| 1094 | setReviewOpen(false); | |
| 1095 | }} | |
| 1096 | /> | |
| 1097 | </div> | |
| 1098 | ) : ( | |
| 1099 | <Button | |
| 1100 | className={cn( | |
| 1101 | "pointer-events-auto absolute bottom-3 rounded-full shadow-lg", | |
| 1102 | detail.capabilities.comment && detail.viewerPermissions.comment | |
| 1103 | ? "right-16" | |
| 1104 | : "right-4", | |
| 1105 | )} | |
| 1106 | onClick={() => setReviewOpen(true)} | |
| 1107 | size="compact" | |
| 1108 | variant="glass" | |
| 1109 | > | |
| 1110 | <MessageSquareIcon className="size-3.5" /> | |
| 1111 | Review | |
| 1112 | {pendingComments.length > 0 ? ( | |
| 1113 | <span className="flex size-4 items-center justify-center rounded-full bg-accent text-[10px] tabular-nums text-accent-foreground"> | |
| 1114 | {pendingComments.length} | |
| 1115 | </span> | |
| 1116 | ) : null} | |
| 1117 | </Button> | |
| 1118 | )} | |
| 1119 | </div> | |
| 1120 | ); | |
| 1121 | 1050 | // A rebase or a force-push can take the scoped commit out of the change. Its diff may still |
| 1122 | 1051 | // be reachable on the host, but it is no longer part of what is being reviewed, so the scope |
| 1123 | 1052 | // goes back to the whole change rather than sitting under a name nothing matches. |
| ... | ... | @@ -1392,29 +1321,23 @@ function PullRequestCodeTab({ |
| 1392 | 1321 | ); |
| 1393 | 1322 | // The toolbar rides above every branch below, not just the one with a patch in it: a commit |
| 1394 | 1323 | // whose diff is empty or unreadable still needs the scope dropdown that got the reader there. |
| 1395 | const withReviewBar = (body: ReactNode) => ( | |
| 1324 | const withToolbar = (body: ReactNode) => ( | |
| 1396 | 1325 | <div className="flex h-full min-h-0 flex-col"> |
| 1397 | 1326 | {toolbar} |
| 1398 | {/* The overlay is anchored to this wrapper, not the scroller: absolute positioning | |
| 1399 | inside an overflowing element tracks the content's bottom edge, which would carry | |
| 1400 | the trigger away with the first scroll. */} | |
| 1401 | <div className="relative min-h-0 flex-1"> | |
| 1402 | <div className="h-full overflow-auto">{body}</div> | |
| 1403 | {reviewOverlay} | |
| 1404 | </div> | |
| 1327 | <div className="min-h-0 flex-1 overflow-auto">{body}</div> | |
| 1405 | 1328 | </div> |
| 1406 | 1329 | ); |
| 1407 | 1330 | |
| 1408 | 1331 | // Under the toolbar rather than in place of it, so choosing a commit does not take the |
| 1409 | 1332 | // dropdown that was just used off the screen while its diff loads. |
| 1410 | 1333 | if (diffQuery.isPending && loadedSlices.length === 0) { |
| 1411 | return withReviewBar(<DiffPanelLoadingState label="Loading pull request diff..." />); | |
| 1334 | return withToolbar(<DiffPanelLoadingState label="Loading pull request diff..." />); | |
| 1412 | 1335 | } |
| 1413 | 1336 | |
| 1414 | 1337 | // A slice that fails once there are files on screen is reported at the end of them instead: |
| 1415 | 1338 | // the diff already read is worth more than the error that stopped it growing. |
| 1416 | 1339 | if (diffQuery.error && loadedSlices.length === 0) { |
| 1417 | return withReviewBar( | |
| 1340 | return withToolbar( | |
| 1418 | 1341 | <p className="px-4 py-5 text-sm text-muted-foreground">{diffQuery.error}</p>, |
| 1419 | 1342 | ); |
| 1420 | 1343 | } |
| ... | ... | @@ -1428,7 +1351,7 @@ function PullRequestCodeTab({ |
| 1428 | 1351 | ? parsedSlices.flatMap((parsed) => (parsed?.kind === "raw" ? [parsed] : [])) |
| 1429 | 1352 | : []; |
| 1430 | 1353 | if (files.length === 0 && rawSlices.length > 0) { |
| 1431 | return withReviewBar( | |
| 1354 | return withToolbar( | |
| 1432 | 1355 | <div className="space-y-4 px-4 py-5"> |
| 1433 | 1356 | {rawSlices.map((slice) => ( |
| 1434 | 1357 | <div key={`${slice.reason}:${slice.text.slice(0, 64)}`} className="space-y-2"> |
| ... | ... | @@ -1441,7 +1364,7 @@ function PullRequestCodeTab({ |
| 1441 | 1364 | } |
| 1442 | 1365 | |
| 1443 | 1366 | if (items.length === 0 && nextCursor === null) { |
| 1444 | return withReviewBar( | |
| 1367 | return withToolbar( | |
| 1445 | 1368 | <p className="px-4 py-5 text-sm text-muted-foreground"> |
| 1446 | 1369 | {commit === null |
| 1447 | 1370 | ? "This pull request has no file changes." |
| ... | ... | @@ -1595,7 +1518,6 @@ function PullRequestCodeTab({ |
| 1595 | 1518 | renderAnnotation={renderAnnotation} |
| 1596 | 1519 | unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS} |
| 1597 | 1520 | /> |
| 1598 | {reviewOverlay} | |
| 1599 | 1521 | </div> |
| 1600 | 1522 | {fileTreeOpen ? ( |
| 1601 | 1523 | <aside className="flex w-[min(20rem,40%)] min-w-48 shrink-0 border-l border-border/60"> |
apps/web/src/components/pullRequest/PullRequestCommentComposer.tsx deleted-171| ... | ... | @@ -1,171 +0,0 @@ |
| 1 | import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3tools/contracts"; | |
| 2 | import { MessageSquareIcon, SendIcon, XIcon } from "lucide-react"; | |
| 3 | import { useRef, useState } from "react"; | |
| 4 | ||
| 5 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 6 | import { pullRequestEnvironment } from "~/state/pullRequests"; | |
| 7 | ||
| 8 | import { Button } from "../ui/button"; | |
| 9 | import { Popover, PopoverClose, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover"; | |
| 10 | import { Textarea } from "../ui/textarea"; | |
| 11 | import { toastManager } from "../ui/toast"; | |
| 12 | import { PullRequestGlyph } from "./pullRequestIcons"; | |
| 13 | ||
| 14 | export function PullRequestCommentComposer({ | |
| 15 | environmentId, | |
| 16 | reference, | |
| 17 | detail, | |
| 18 | actionPending, | |
| 19 | onCommentAction, | |
| 20 | onCommented, | |
| 21 | }: { | |
| 22 | environmentId: EnvironmentId; | |
| 23 | reference: PullRequestRef; | |
| 24 | detail: PullRequestDetailView; | |
| 25 | actionPending: boolean; | |
| 26 | onCommentAction: ( | |
| 27 | body: string, | |
| 28 | action: "close" | "reopen", | |
| 29 | ) => Promise<{ readonly commentPosted: boolean }>; | |
| 30 | onCommented: () => void; | |
| 31 | }) { | |
| 32 | const [open, setOpen] = useState(false); | |
| 33 | const [body, setBody] = useState(""); | |
| 34 | const [submitting, setSubmitting] = useState<"comment" | "close" | "reopen" | null>(null); | |
| 35 | const textareaRef = useRef<HTMLTextAreaElement>(null); | |
| 36 | const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false }); | |
| 37 | const followUpAction = | |
| 38 | detail.state === "open" && | |
| 39 | detail.capabilities.actions.includes("close") && | |
| 40 | detail.viewerPermissions.actions.includes("close") | |
| 41 | ? ("close" as const) | |
| 42 | : detail.state === "closed" && | |
| 43 | detail.capabilities.actions.includes("reopen") && | |
| 44 | detail.viewerPermissions.actions.includes("reopen") | |
| 45 | ? ("reopen" as const) | |
| 46 | : null; | |
| 47 | ||
| 48 | const submit = async (action: "comment" | "close" | "reopen") => { | |
| 49 | const trimmed = body.trim(); | |
| 50 | if (trimmed.length === 0 || submitting !== null || actionPending) return; | |
| 51 | setSubmitting(action); | |
| 52 | if (action !== "comment") { | |
| 53 | const result = await onCommentAction(trimmed, action); | |
| 54 | if (result.commentPosted) { | |
| 55 | setBody(""); | |
| 56 | setOpen(false); | |
| 57 | } | |
| 58 | setSubmitting(null); | |
| 59 | return; | |
| 60 | } | |
| 61 | const result = await postComment({ | |
| 62 | environmentId, | |
| 63 | input: { | |
| 64 | ...reference, | |
| 65 | body: trimmed, | |
| 66 | }, | |
| 67 | }); | |
| 68 | if (result._tag === "Failure") { | |
| 69 | setSubmitting(null); | |
| 70 | toastManager.add({ type: "error", title: "Could not post the comment" }); | |
| 71 | return; | |
| 72 | } | |
| 73 | setBody(""); | |
| 74 | setSubmitting(null); | |
| 75 | setOpen(false); | |
| 76 | onCommented(); | |
| 77 | }; | |
| 78 | ||
| 79 | return ( | |
| 80 | <Popover open={open} onOpenChange={setOpen}> | |
| 81 | <PopoverTrigger | |
| 82 | render={ | |
| 83 | <Button | |
| 84 | size="icon" | |
| 85 | variant="glass" | |
| 86 | className="rounded-full shadow-lg [--glass-opacity:50%]" | |
| 87 | /> | |
| 88 | } | |
| 89 | aria-label="Comment on pull request" | |
| 90 | > | |
| 91 | <MessageSquareIcon className="size-4" /> | |
| 92 | </PopoverTrigger> | |
| 93 | <PopoverPopup | |
| 94 | side="top" | |
| 95 | align="end" | |
| 96 | sideOffset={8} | |
| 97 | className="w-96 max-w-[calc(100vw-2rem)]" | |
| 98 | initialFocus={textareaRef} | |
| 99 | > | |
| 100 | <div className="mb-3 flex items-center justify-between gap-2"> | |
| 101 | <PopoverTitle className="text-sm">Comment on pull request</PopoverTitle> | |
| 102 | <PopoverClose | |
| 103 | render={<Button size="icon-xs" variant="ghost" />} | |
| 104 | aria-label="Close comment composer" | |
| 105 | > | |
| 106 | <XIcon className="size-3.5" /> | |
| 107 | </PopoverClose> | |
| 108 | </div> | |
| 109 | <div className="space-y-2"> | |
| 110 | <Textarea | |
| 111 | ref={textareaRef} | |
| 112 | className="[&_textarea]:max-h-64" | |
| 113 | // Locked while posting: the body is cleared on success, which would otherwise throw | |
| 114 | // away a new draft typed while the request was still in flight. | |
| 115 | disabled={submitting !== null || actionPending} | |
| 116 | value={body} | |
| 117 | rows={3} | |
| 118 | placeholder="Leave a comment" | |
| 119 | aria-label="Comment on this pull request" | |
| 120 | onChange={(event) => setBody(event.target.value)} | |
| 121 | onKeyDown={(event) => { | |
| 122 | if (event.nativeEvent.isComposing || event.keyCode === 229) return; | |
| 123 | if ( | |
| 124 | event.key === "Enter" && | |
| 125 | (event.metaKey || event.ctrlKey) && | |
| 126 | !event.shiftKey && | |
| 127 | !event.altKey | |
| 128 | ) { | |
| 129 | event.preventDefault(); | |
| 130 | event.stopPropagation(); | |
| 131 | if (!event.repeat) void submit("comment"); | |
| 132 | } | |
| 133 | }} | |
| 134 | /> | |
| 135 | <div className="flex flex-wrap justify-end gap-2"> | |
| 136 | {followUpAction === null ? null : ( | |
| 137 | <Button | |
| 138 | size="xs" | |
| 139 | variant={followUpAction === "close" ? "destructive-outline" : "outline"} | |
| 140 | disabled={body.trim().length === 0 || submitting !== null || actionPending} | |
| 141 | onClick={() => void submit(followUpAction)} | |
| 142 | > | |
| 143 | {followUpAction === "close" ? ( | |
| 144 | <PullRequestGlyph.closed className="size-3.5" /> | |
| 145 | ) : ( | |
| 146 | <PullRequestGlyph.reopen className="size-3.5" /> | |
| 147 | )} | |
| 148 | {submitting === followUpAction | |
| 149 | ? followUpAction === "close" | |
| 150 | ? "Closing..." | |
| 151 | : "Reopening..." | |
| 152 | : followUpAction === "close" | |
| 153 | ? "Close with comment" | |
| 154 | : "Reopen with comment"} | |
| 155 | </Button> | |
| 156 | )} | |
| 157 | <Button | |
| 158 | size="xs" | |
| 159 | variant="outline" | |
| 160 | disabled={body.trim().length === 0 || submitting !== null || actionPending} | |
| 161 | onClick={() => void submit("comment")} | |
| 162 | > | |
| 163 | <SendIcon className="size-3.5" /> | |
| 164 | {submitting === "comment" ? "Posting..." : "Comment"} | |
| 165 | </Button> | |
| 166 | </div> | |
| 167 | </div> | |
| 168 | </PopoverPopup> | |
| 169 | </Popover> | |
| 170 | ); | |
| 171 | } |
apps/web/src/components/pullRequest/PullRequestCommentForm.tsx created+145| ... | ... | @@ -0,0 +1,145 @@ |
| 1 | /** | |
| 2 | * The comment half of the floating composer: a remark on the pull request itself, optionally | |
| 3 | * the one that closes or reopens it. The popover around it belongs to PullRequestComposer. | |
| 4 | */ | |
| 5 | import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3tools/contracts"; | |
| 6 | import { SendIcon } from "lucide-react"; | |
| 7 | import { useState, type RefObject } from "react"; | |
| 8 | ||
| 9 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 10 | import { pullRequestEnvironment } from "~/state/pullRequests"; | |
| 11 | ||
| 12 | import { Button } from "../ui/button"; | |
| 13 | import { Textarea } from "../ui/textarea"; | |
| 14 | import { toastManager } from "../ui/toast"; | |
| 15 | import { PullRequestGlyph } from "./pullRequestIcons"; | |
| 16 | ||
| 17 | export function PullRequestCommentForm({ | |
| 18 | environmentId, | |
| 19 | reference, | |
| 20 | detail, | |
| 21 | actionPending, | |
| 22 | textareaRef, | |
| 23 | onCommentAction, | |
| 24 | onCommented, | |
| 25 | onClose, | |
| 26 | }: { | |
| 27 | environmentId: EnvironmentId; | |
| 28 | reference: PullRequestRef; | |
| 29 | detail: PullRequestDetailView; | |
| 30 | actionPending: boolean; | |
| 31 | textareaRef: RefObject<HTMLTextAreaElement | null>; | |
| 32 | onCommentAction: ( | |
| 33 | body: string, | |
| 34 | action: "close" | "reopen", | |
| 35 | ) => Promise<{ readonly commentPosted: boolean }>; | |
| 36 | onCommented: () => void; | |
| 37 | onClose: () => void; | |
| 38 | }) { | |
| 39 | const [body, setBody] = useState(""); | |
| 40 | const [submitting, setSubmitting] = useState<"comment" | "close" | "reopen" | null>(null); | |
| 41 | const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false }); | |
| 42 | const followUpAction = | |
| 43 | detail.state === "open" && | |
| 44 | detail.capabilities.actions.includes("close") && | |
| 45 | detail.viewerPermissions.actions.includes("close") | |
| 46 | ? ("close" as const) | |
| 47 | : detail.state === "closed" && | |
| 48 | detail.capabilities.actions.includes("reopen") && | |
| 49 | detail.viewerPermissions.actions.includes("reopen") | |
| 50 | ? ("reopen" as const) | |
| 51 | : null; | |
| 52 | ||
| 53 | const submit = async (action: "comment" | "close" | "reopen") => { | |
| 54 | const trimmed = body.trim(); | |
| 55 | if (trimmed.length === 0 || submitting !== null || actionPending) return; | |
| 56 | setSubmitting(action); | |
| 57 | if (action !== "comment") { | |
| 58 | const result = await onCommentAction(trimmed, action); | |
| 59 | if (result.commentPosted) { | |
| 60 | setBody(""); | |
| 61 | onClose(); | |
| 62 | } | |
| 63 | setSubmitting(null); | |
| 64 | return; | |
| 65 | } | |
| 66 | const result = await postComment({ | |
| 67 | environmentId, | |
| 68 | input: { | |
| 69 | ...reference, | |
| 70 | body: trimmed, | |
| 71 | }, | |
| 72 | }); | |
| 73 | if (result._tag === "Failure") { | |
| 74 | setSubmitting(null); | |
| 75 | toastManager.add({ type: "error", title: "Could not post the comment" }); | |
| 76 | return; | |
| 77 | } | |
| 78 | setBody(""); | |
| 79 | setSubmitting(null); | |
| 80 | onClose(); | |
| 81 | onCommented(); | |
| 82 | }; | |
| 83 | ||
| 84 | return ( | |
| 85 | <div className="space-y-2"> | |
| 86 | <Textarea | |
| 87 | ref={textareaRef} | |
| 88 | className="[&_textarea]:max-h-64" | |
| 89 | // Locked while posting: the body is cleared on success, which would otherwise throw | |
| 90 | // away a new draft typed while the request was still in flight. | |
| 91 | disabled={submitting !== null || actionPending} | |
| 92 | value={body} | |
| 93 | rows={3} | |
| 94 | placeholder="Leave a comment" | |
| 95 | aria-label="Comment on this pull request" | |
| 96 | onChange={(event) => setBody(event.target.value)} | |
| 97 | onKeyDown={(event) => { | |
| 98 | if (event.nativeEvent.isComposing || event.keyCode === 229) return; | |
| 99 | if ( | |
| 100 | event.key === "Enter" && | |
| 101 | (event.metaKey || event.ctrlKey) && | |
| 102 | !event.shiftKey && | |
| 103 | !event.altKey | |
| 104 | ) { | |
| 105 | event.preventDefault(); | |
| 106 | event.stopPropagation(); | |
| 107 | if (!event.repeat) void submit("comment"); | |
| 108 | } | |
| 109 | }} | |
| 110 | /> | |
| 111 | <div className="flex flex-wrap justify-end gap-2"> | |
| 112 | {followUpAction === null ? null : ( | |
| 113 | <Button | |
| 114 | size="xs" | |
| 115 | variant={followUpAction === "close" ? "destructive-outline" : "outline"} | |
| 116 | disabled={body.trim().length === 0 || submitting !== null || actionPending} | |
| 117 | onClick={() => void submit(followUpAction)} | |
| 118 | > | |
| 119 | {followUpAction === "close" ? ( | |
| 120 | <PullRequestGlyph.closed className="size-3.5" /> | |
| 121 | ) : ( | |
| 122 | <PullRequestGlyph.reopen className="size-3.5" /> | |
| 123 | )} | |
| 124 | {submitting === followUpAction | |
| 125 | ? followUpAction === "close" | |
| 126 | ? "Closing..." | |
| 127 | : "Reopening..." | |
| 128 | : followUpAction === "close" | |
| 129 | ? "Close with comment" | |
| 130 | : "Reopen with comment"} | |
| 131 | </Button> | |
| 132 | )} | |
| 133 | <Button | |
| 134 | size="xs" | |
| 135 | variant="outline" | |
| 136 | disabled={body.trim().length === 0 || submitting !== null || actionPending} | |
| 137 | onClick={() => void submit("comment")} | |
| 138 | > | |
| 139 | <SendIcon className="size-3.5" /> | |
| 140 | {submitting === "comment" ? "Posting..." : "Comment"} | |
| 141 | </Button> | |
| 142 | </div> | |
| 143 | </div> | |
| 144 | ); | |
| 145 | } |
apps/web/src/components/pullRequest/PullRequestComposer.tsx created+195| ... | ... | @@ -0,0 +1,195 @@ |
| 1 | /** | |
| 2 | * The single floating control over a pull request. Commenting on the change and submitting the | |
| 3 | * review that carries the Code tab's line comments used to float as two buttons that crowded | |
| 4 | * each other and read as the same offer twice; they are two modes of one composer now. | |
| 5 | * | |
| 6 | * Opening picks the mode with work waiting in it, so a reader who has collected line comments | |
| 7 | * lands on the review and everyone else lands on the comment box. Either mode's draft survives | |
| 8 | * the toggle: they are separate texts going to separate places, and merging them would send a | |
| 9 | * summary as a comment or the reverse. | |
| 10 | */ | |
| 11 | import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3tools/contracts"; | |
| 12 | import { MessageSquareIcon, Trash2Icon, XIcon } from "lucide-react"; | |
| 13 | import { useRef, useState } from "react"; | |
| 14 | ||
| 15 | import { Button } from "../ui/button"; | |
| 16 | import { Popover, PopoverClose, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover"; | |
| 17 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; | |
| 18 | import { PullRequestCommentForm } from "./PullRequestCommentForm"; | |
| 19 | import { PullRequestReviewForm } from "./PullRequestReviewForm"; | |
| 20 | import { | |
| 21 | pullRequestReviewKey, | |
| 22 | usePendingReviewComments, | |
| 23 | usePullRequestReviewStore, | |
| 24 | } from "./pullRequestReviewStore"; | |
| 25 | ||
| 26 | export function PullRequestComposer({ | |
| 27 | environmentId, | |
| 28 | reference, | |
| 29 | detail, | |
| 30 | actionPending, | |
| 31 | onCommentAction, | |
| 32 | onCommented, | |
| 33 | onReviewSubmitted, | |
| 34 | }: { | |
| 35 | environmentId: EnvironmentId; | |
| 36 | reference: PullRequestRef; | |
| 37 | detail: PullRequestDetailView; | |
| 38 | actionPending: boolean; | |
| 39 | onCommentAction: ( | |
| 40 | body: string, | |
| 41 | action: "close" | "reopen", | |
| 42 | ) => Promise<{ readonly commentPosted: boolean }>; | |
| 43 | onCommented: () => void; | |
| 44 | onReviewSubmitted: () => void; | |
| 45 | }) { | |
| 46 | const [open, setOpen] = useState(false); | |
| 47 | const [reviewPending, setReviewPending] = useState(false); | |
| 48 | const [requestedMode, setRequestedMode] = useState<"comment" | "review">("comment"); | |
| 49 | const commentRef = useRef<HTMLTextAreaElement>(null); | |
| 50 | const reviewRef = useRef<HTMLTextAreaElement>(null); | |
| 51 | const pendingComments = usePendingReviewComments(reference); | |
| 52 | const clearComments = usePullRequestReviewStore((store) => store.clear); | |
| 53 | // A summary typed but not sent is review work too, and it outlives the popover. Selected as a | |
| 54 | // boolean rather than the text, so typing one does not re-render the composer per keystroke. | |
| 55 | const reviewKey = pullRequestReviewKey(reference); | |
| 56 | const summaryStarted = usePullRequestReviewStore( | |
| 57 | (store) => (store.summaries[reviewKey] ?? "").trim().length > 0, | |
| 58 | ); | |
| 59 | const reviewStarted = pendingComments.length > 0 || summaryStarted; | |
| 60 | ||
| 61 | // What is offered is the intersection of two different questions: what this host can do at | |
| 62 | // all, and what this account may do on this repository. Either one saying no means a control | |
| 63 | // that would only ever end in a refusal. | |
| 64 | const canComment = detail.capabilities.comment && detail.viewerPermissions.comment; | |
| 65 | const verdicts = detail.capabilities.review.verdicts.filter((verdict) => | |
| 66 | detail.viewerPermissions.verdicts.includes(verdict), | |
| 67 | ); | |
| 68 | if (!canComment && verdicts.length === 0) return null; | |
| 69 | ||
| 70 | const mode = canComment ? (verdicts.length === 0 ? "comment" : requestedMode) : "review"; | |
| 71 | ||
| 72 | return ( | |
| 73 | <Popover | |
| 74 | open={open} | |
| 75 | onOpenChange={(next) => { | |
| 76 | if (next) setRequestedMode(reviewStarted ? "review" : "comment"); | |
| 77 | setOpen(next); | |
| 78 | }} | |
| 79 | > | |
| 80 | <PopoverTrigger | |
| 81 | render={ | |
| 82 | <Button | |
| 83 | size="icon" | |
| 84 | variant="glass" | |
| 85 | className="rounded-full shadow-lg [--glass-opacity:50%]" | |
| 86 | /> | |
| 87 | } | |
| 88 | // The only label this control has, so the pending count belongs in it: the badge beside | |
| 89 | // the icon is decorative and a reader who cannot see it still needs the number. | |
| 90 | aria-label={ | |
| 91 | pendingComments.length > 0 | |
| 92 | ? `Review pull request, ${pendingComments.length} ${pendingComments.length === 1 ? "comment" : "comments"} pending` | |
| 93 | : reviewStarted || !canComment | |
| 94 | ? "Review pull request" | |
| 95 | : "Comment on pull request" | |
| 96 | } | |
| 97 | > | |
| 98 | <MessageSquareIcon className="size-4" /> | |
| 99 | {pendingComments.length > 0 ? ( | |
| 100 | <span | |
| 101 | aria-hidden | |
| 102 | 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-[9px] font-semibold tabular-nums text-white" | |
| 103 | > | |
| 104 | {pendingComments.length} | |
| 105 | </span> | |
| 106 | ) : null} | |
| 107 | </PopoverTrigger> | |
| 108 | <PopoverPopup | |
| 109 | keepMounted | |
| 110 | side="top" | |
| 111 | align="end" | |
| 112 | sideOffset={8} | |
| 113 | className="w-96 max-w-[calc(100vw-2rem)]" | |
| 114 | initialFocus={mode === "review" ? reviewRef : commentRef} | |
| 115 | aria-label="Pull request composer" | |
| 116 | > | |
| 117 | <div className="mb-3 flex items-center justify-between gap-2"> | |
| 118 | {canComment && verdicts.length > 0 ? ( | |
| 119 | <ToggleGroup | |
| 120 | aria-label="Composer mode" | |
| 121 | variant="segmented" | |
| 122 | value={[mode]} | |
| 123 | onValueChange={(next) => { | |
| 124 | const value = next[0]; | |
| 125 | if (value === "comment" || value === "review") setRequestedMode(value); | |
| 126 | }} | |
| 127 | > | |
| 128 | <Toggle value="comment">Comment</Toggle> | |
| 129 | <Toggle value="review"> | |
| 130 | {pendingComments.length > 0 ? `Review (${pendingComments.length})` : "Review"} | |
| 131 | </Toggle> | |
| 132 | </ToggleGroup> | |
| 133 | ) : ( | |
| 134 | <PopoverTitle className="text-sm"> | |
| 135 | {mode === "review" ? "Review pull request" : "Comment on pull request"} | |
| 136 | </PopoverTitle> | |
| 137 | )} | |
| 138 | <div className="flex items-center gap-1"> | |
| 139 | {mode === "review" && pendingComments.length > 0 ? ( | |
| 140 | <Button | |
| 141 | size="icon-xs" | |
| 142 | variant="ghost" | |
| 143 | aria-label="Discard pending line comments" | |
| 144 | title="Discard pending line comments" | |
| 145 | disabled={reviewPending} | |
| 146 | onClick={() => clearComments(reviewKey)} | |
| 147 | > | |
| 148 | <Trash2Icon className="size-3.5" /> | |
| 149 | </Button> | |
| 150 | ) : null} | |
| 151 | <PopoverClose | |
| 152 | render={<Button size="icon-xs" variant="ghost" />} | |
| 153 | aria-label="Close composer" | |
| 154 | > | |
| 155 | <XIcon className="size-3.5" /> | |
| 156 | </PopoverClose> | |
| 157 | </div> | |
| 158 | </div> | |
| 159 | {/* Keep both forms mounted across toggles and dismissal so drafts and in-flight | |
| 160 | submit guards survive closing and reopening the composer. */} | |
| 161 | {verdicts.length > 0 ? ( | |
| 162 | <div hidden={mode !== "review"}> | |
| 163 | <PullRequestReviewForm | |
| 164 | environmentId={environmentId} | |
| 165 | reference={reference} | |
| 166 | verdicts={verdicts} | |
| 167 | requestChangesSummaryRequired={detail.provider === "forgejo"} | |
| 168 | textareaRef={reviewRef} | |
| 169 | pending={reviewPending} | |
| 170 | onPendingChange={setReviewPending} | |
| 171 | onSubmitted={() => { | |
| 172 | setOpen(false); | |
| 173 | onReviewSubmitted(); | |
| 174 | }} | |
| 175 | /> | |
| 176 | </div> | |
| 177 | ) : null} | |
| 178 | {canComment ? ( | |
| 179 | <div hidden={mode !== "comment"}> | |
| 180 | <PullRequestCommentForm | |
| 181 | environmentId={environmentId} | |
| 182 | reference={reference} | |
| 183 | detail={detail} | |
| 184 | actionPending={actionPending} | |
| 185 | textareaRef={commentRef} | |
| 186 | onCommentAction={onCommentAction} | |
| 187 | onCommented={onCommented} | |
| 188 | onClose={() => setOpen(false)} | |
| 189 | /> | |
| 190 | </div> | |
| 191 | ) : null} | |
| 192 | </PopoverPopup> | |
| 193 | </Popover> | |
| 194 | ); | |
| 195 | } |
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+4-3| ... | ... | @@ -130,7 +130,7 @@ import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; |
| 130 | 130 | import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab"; |
| 131 | 131 | import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; |
| 132 | 132 | import { PullRequestMarkdownContext } from "./PullRequestMarkdown"; |
| 133 | import { PullRequestCommentComposer } from "./PullRequestCommentComposer"; | |
| 133 | import { PullRequestComposer } from "./PullRequestComposer"; | |
| 134 | 134 | import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; |
| 135 | 135 | import { PullRequestTimelineTab } from "./PullRequestTimelineTab"; |
| 136 | 136 | import { |
| ... | ... | @@ -2793,9 +2793,9 @@ export function PullRequestDetailPanel({ |
| 2793 | 2793 | </div> |
| 2794 | 2794 | |
| 2795 | 2795 | {/* Float over the content; do not reserve a footer or padding in the PR tabs. */} |
| 2796 | {detail?.capabilities.comment && detail.viewerPermissions.comment ? ( | |
| 2796 | {detail ? ( | |
| 2797 | 2797 | <div className="absolute right-4 bottom-3 z-20"> |
| 2798 | <PullRequestCommentComposer | |
| 2798 | <PullRequestComposer | |
| 2799 | 2799 | key={JSON.stringify([ |
| 2800 | 2800 | environmentId, |
| 2801 | 2801 | reference.projectId, |
| ... | ... | @@ -2809,6 +2809,7 @@ export function PullRequestDetailPanel({ |
| 2809 | 2809 | actionPending={actionPending} |
| 2810 | 2810 | onCommentAction={performCommentAction} |
| 2811 | 2811 | onCommented={refreshDetail} |
| 2812 | onReviewSubmitted={refreshDetail} | |
| 2812 | 2813 | /> |
| 2813 | 2814 | </div> |
| 2814 | 2815 | ) : null} |
apps/web/src/components/pullRequest/PullRequestReviewBar.tsx deleted-161| ... | ... | @@ -1,161 +0,0 @@ |
| 1 | /** | |
| 2 | * The review form floated over the Code tab: how many comments the review is holding, its | |
| 3 | * summary, and the verdict that sends the lot. Hidden entirely on a host that cannot take a | |
| 4 | * review. The glass card frame belongs to the caller (PullRequestCodeTab), which is why this | |
| 5 | * only contributes its own padding. | |
| 6 | */ | |
| 7 | import type { EnvironmentId, PullRequestRef, PullRequestReviewVerdict } from "@t3tools/contracts"; | |
| 8 | import { CheckIcon, MessageSquareIcon, XCircleIcon } from "lucide-react"; | |
| 9 | import { useState, type ReactNode } from "react"; | |
| 10 | ||
| 11 | import { pullRequestEnvironment } from "~/state/pullRequests"; | |
| 12 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 13 | ||
| 14 | import { Button } from "../ui/button"; | |
| 15 | import { Textarea } from "../ui/textarea"; | |
| 16 | import { toastManager } from "../ui/toast"; | |
| 17 | import { | |
| 18 | pullRequestReviewKey, | |
| 19 | usePendingReviewComments, | |
| 20 | usePullRequestReviewStore, | |
| 21 | } from "./pullRequestReviewStore"; | |
| 22 | ||
| 23 | const VERDICTS: ReadonlyArray<{ | |
| 24 | readonly value: PullRequestReviewVerdict; | |
| 25 | readonly label: string; | |
| 26 | readonly sent: string; | |
| 27 | readonly icon: ReactNode; | |
| 28 | }> = [ | |
| 29 | { | |
| 30 | value: "comment", | |
| 31 | label: "Comment", | |
| 32 | sent: "Review submitted", | |
| 33 | icon: <MessageSquareIcon className="size-3" />, | |
| 34 | }, | |
| 35 | { | |
| 36 | value: "approve", | |
| 37 | label: "Approve", | |
| 38 | sent: "Pull request approved", | |
| 39 | icon: <CheckIcon className="size-3" />, | |
| 40 | }, | |
| 41 | { | |
| 42 | value: "request-changes", | |
| 43 | label: "Request changes", | |
| 44 | sent: "Changes requested", | |
| 45 | icon: <XCircleIcon className="size-3" />, | |
| 46 | }, | |
| 47 | ]; | |
| 48 | ||
| 49 | export function PullRequestReviewBar({ | |
| 50 | environmentId, | |
| 51 | reference, | |
| 52 | verdicts, | |
| 53 | requestChangesSummaryRequired, | |
| 54 | onSubmitted, | |
| 55 | }: { | |
| 56 | environmentId: EnvironmentId; | |
| 57 | reference: PullRequestRef; | |
| 58 | verdicts: ReadonlyArray<PullRequestReviewVerdict>; | |
| 59 | requestChangesSummaryRequired: boolean; | |
| 60 | onSubmitted: () => void; | |
| 61 | }) { | |
| 62 | const [pending, setPending] = useState(false); | |
| 63 | const comments = usePendingReviewComments(reference); | |
| 64 | const reviewKey = pullRequestReviewKey(reference); | |
| 65 | // The panel stays mounted while the selected pull request changes. Keeping summaries beside | |
| 66 | // the keyed line-comment drafts makes the selected pull request's body correct on the first | |
| 67 | // render, before an effect could reset state left behind by the previous one. | |
| 68 | const body = usePullRequestReviewStore((store) => store.summaries[reviewKey] ?? ""); | |
| 69 | const clear = usePullRequestReviewStore((store) => store.clear); | |
| 70 | const removeComments = usePullRequestReviewStore((store) => store.removeComments); | |
| 71 | const setSummary = usePullRequestReviewStore((store) => store.setSummary); | |
| 72 | const clearSummary = usePullRequestReviewStore((store) => store.clearSummary); | |
| 73 | const submitReview = useAtomCommand(pullRequestEnvironment.submitReview, { | |
| 74 | reportFailure: false, | |
| 75 | }); | |
| 76 | ||
| 77 | const offered = VERDICTS.filter((verdict) => verdicts.includes(verdict.value)); | |
| 78 | if (offered.length === 0) return null; | |
| 79 | ||
| 80 | const submit = async (verdict: (typeof VERDICTS)[number]) => { | |
| 81 | if (pending) return; | |
| 82 | const submittedBody = body; | |
| 83 | const submittedComments = comments; | |
| 84 | setPending(true); | |
| 85 | const result = await submitReview({ | |
| 86 | environmentId, | |
| 87 | input: { | |
| 88 | ...reference, | |
| 89 | verdict: verdict.value, | |
| 90 | body: submittedBody, | |
| 91 | comments: submittedComments, | |
| 92 | }, | |
| 93 | }); | |
| 94 | setPending(false); | |
| 95 | if (result._tag === "Failure") { | |
| 96 | // The draft is kept: whatever went wrong, retyping the review is not the answer. | |
| 97 | toastManager.add({ type: "error", title: "The review could not be submitted" }); | |
| 98 | return; | |
| 99 | } | |
| 100 | // More remarks may have been added while the host was accepting this snapshot. Leave those, | |
| 101 | // and any summary revised in the meantime, ready for the next review. | |
| 102 | removeComments( | |
| 103 | reviewKey, | |
| 104 | submittedComments.map((comment) => comment.id), | |
| 105 | ); | |
| 106 | clearSummary(reviewKey, submittedBody); | |
| 107 | toastManager.add({ type: "success", title: verdict.sent }); | |
| 108 | onSubmitted(); | |
| 109 | }; | |
| 110 | ||
| 111 | // Forgejo requires a summary when requesting changes, even with inline comments. | |
| 112 | const canSubmit = (verdict: PullRequestReviewVerdict) => | |
| 113 | verdict === "request-changes" && requestChangesSummaryRequired | |
| 114 | ? body.trim().length > 0 | |
| 115 | : verdict === "approve" || body.trim().length > 0 || comments.length > 0; | |
| 116 | ||
| 117 | return ( | |
| 118 | <div className="px-4 py-3"> | |
| 119 | <div className="flex items-center gap-2 text-xs text-muted-foreground"> | |
| 120 | <span> | |
| 121 | {comments.length === 0 | |
| 122 | ? "No line comments yet" | |
| 123 | : `${comments.length} ${comments.length === 1 ? "comment" : "comments"} pending`} | |
| 124 | </span> | |
| 125 | {comments.length > 0 ? ( | |
| 126 | <Button size="xs" variant="ghost" disabled={pending} onClick={() => clear(reviewKey)}> | |
| 127 | Discard | |
| 128 | </Button> | |
| 129 | ) : null} | |
| 130 | </div> | |
| 131 | <Textarea | |
| 132 | size="sm" | |
| 133 | className="mt-2" | |
| 134 | value={body} | |
| 135 | placeholder={ | |
| 136 | requestChangesSummaryRequired && verdicts.includes("request-changes") | |
| 137 | ? "Summarize your review (required to request changes)" | |
| 138 | : "Summarize your review (optional)" | |
| 139 | } | |
| 140 | aria-label="Review summary" | |
| 141 | onChange={(event) => setSummary(reviewKey, event.target.value)} | |
| 142 | /> | |
| 143 | <div className="mt-2 flex flex-wrap justify-end gap-2"> | |
| 144 | {offered.map((verdict) => ( | |
| 145 | <Button | |
| 146 | key={verdict.value} | |
| 147 | size="xs" | |
| 148 | variant={verdict.value === "comment" ? "outline" : "default"} | |
| 149 | disabled={pending || !canSubmit(verdict.value)} | |
| 150 | onClick={() => void submit(verdict)} | |
| 151 | > | |
| 152 | <span className="flex items-center gap-1.5"> | |
| 153 | {verdict.icon} | |
| 154 | {verdict.label} | |
| 155 | </span> | |
| 156 | </Button> | |
| 157 | ))} | |
| 158 | </div> | |
| 159 | </div> | |
| 160 | ); | |
| 161 | } |
apps/web/src/components/pullRequest/PullRequestReviewForm.tsx created+176| ... | ... | @@ -0,0 +1,176 @@ |
| 1 | /** | |
| 2 | * The review half of the floating composer: the summary and the verdict that sends it, together | |
| 3 | * with whatever line comments the review is holding. The count of those lives on the composer's | |
| 4 | * trigger and mode toggle, and each pending card can be dropped from the diff, so neither is | |
| 5 | * repeated here. The popover around it belongs to PullRequestComposer. | |
| 6 | */ | |
| 7 | import type { EnvironmentId, PullRequestRef, PullRequestReviewVerdict } from "@t3tools/contracts"; | |
| 8 | import { CheckIcon, MessageSquareIcon, XCircleIcon } from "lucide-react"; | |
| 9 | import { useState, type ReactNode, type RefObject } from "react"; | |
| 10 | ||
| 11 | import { pullRequestEnvironment } from "~/state/pullRequests"; | |
| 12 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 13 | ||
| 14 | import { Button } from "../ui/button"; | |
| 15 | import { Select, SelectItem, SelectPopup, SelectTrigger } from "../ui/select"; | |
| 16 | import { Textarea } from "../ui/textarea"; | |
| 17 | import { toastManager } from "../ui/toast"; | |
| 18 | import { | |
| 19 | pullRequestReviewKey, | |
| 20 | usePendingReviewComments, | |
| 21 | usePullRequestReviewStore, | |
| 22 | } from "./pullRequestReviewStore"; | |
| 23 | ||
| 24 | const VERDICTS: ReadonlyArray<{ | |
| 25 | readonly value: PullRequestReviewVerdict; | |
| 26 | readonly label: string; | |
| 27 | readonly sent: string; | |
| 28 | readonly icon: ReactNode; | |
| 29 | }> = [ | |
| 30 | { | |
| 31 | value: "comment", | |
| 32 | label: "Comment", | |
| 33 | sent: "Review submitted", | |
| 34 | icon: <MessageSquareIcon className="size-3" />, | |
| 35 | }, | |
| 36 | { | |
| 37 | value: "approve", | |
| 38 | label: "Approve", | |
| 39 | sent: "Pull request approved", | |
| 40 | icon: <CheckIcon className="size-3" />, | |
| 41 | }, | |
| 42 | { | |
| 43 | value: "request-changes", | |
| 44 | label: "Request changes", | |
| 45 | sent: "Changes requested", | |
| 46 | icon: <XCircleIcon className="size-3" />, | |
| 47 | }, | |
| 48 | ]; | |
| 49 | ||
| 50 | export function PullRequestReviewForm({ | |
| 51 | environmentId, | |
| 52 | reference, | |
| 53 | verdicts, | |
| 54 | requestChangesSummaryRequired, | |
| 55 | textareaRef, | |
| 56 | pending, | |
| 57 | onPendingChange, | |
| 58 | onSubmitted, | |
| 59 | }: { | |
| 60 | environmentId: EnvironmentId; | |
| 61 | reference: PullRequestRef; | |
| 62 | verdicts: ReadonlyArray<PullRequestReviewVerdict>; | |
| 63 | requestChangesSummaryRequired: boolean; | |
| 64 | textareaRef: RefObject<HTMLTextAreaElement | null>; | |
| 65 | pending: boolean; | |
| 66 | onPendingChange: (pending: boolean) => void; | |
| 67 | onSubmitted: () => void; | |
| 68 | }) { | |
| 69 | const [requestedVerdict, setRequestedVerdict] = useState<PullRequestReviewVerdict>("comment"); | |
| 70 | const comments = usePendingReviewComments(reference); | |
| 71 | const reviewKey = pullRequestReviewKey(reference); | |
| 72 | // The panel stays mounted while the selected pull request changes. Keeping summaries beside | |
| 73 | // the keyed line-comment drafts makes the selected pull request's body correct on the first | |
| 74 | // render, before an effect could reset state left behind by the previous one. | |
| 75 | const body = usePullRequestReviewStore((store) => store.summaries[reviewKey] ?? ""); | |
| 76 | const removeComments = usePullRequestReviewStore((store) => store.removeComments); | |
| 77 | const setSummary = usePullRequestReviewStore((store) => store.setSummary); | |
| 78 | const clearSummary = usePullRequestReviewStore((store) => store.clearSummary); | |
| 79 | const submitReview = useAtomCommand(pullRequestEnvironment.submitReview, { | |
| 80 | reportFailure: false, | |
| 81 | }); | |
| 82 | ||
| 83 | const offered = VERDICTS.filter((verdict) => verdicts.includes(verdict.value)); | |
| 84 | const selectedVerdict = | |
| 85 | offered.find((verdict) => verdict.value === requestedVerdict) ?? offered[0]; | |
| 86 | ||
| 87 | const submit = async (verdict: (typeof VERDICTS)[number]) => { | |
| 88 | if (pending) return; | |
| 89 | const submittedBody = body; | |
| 90 | const submittedComments = comments; | |
| 91 | onPendingChange(true); | |
| 92 | const result = await submitReview({ | |
| 93 | environmentId, | |
| 94 | input: { | |
| 95 | ...reference, | |
| 96 | verdict: verdict.value, | |
| 97 | body: submittedBody, | |
| 98 | comments: submittedComments, | |
| 99 | }, | |
| 100 | }); | |
| 101 | onPendingChange(false); | |
| 102 | if (result._tag === "Failure") { | |
| 103 | // The draft is kept: whatever went wrong, retyping the review is not the answer. | |
| 104 | toastManager.add({ type: "error", title: "The review could not be submitted" }); | |
| 105 | return; | |
| 106 | } | |
| 107 | // More remarks may have been added while the host was accepting this snapshot. Leave those, | |
| 108 | // and any summary revised in the meantime, ready for the next review. | |
| 109 | removeComments( | |
| 110 | reviewKey, | |
| 111 | submittedComments.map((comment) => comment.id), | |
| 112 | ); | |
| 113 | clearSummary(reviewKey, submittedBody); | |
| 114 | toastManager.add({ type: "success", title: verdict.sent }); | |
| 115 | onSubmitted(); | |
| 116 | }; | |
| 117 | ||
| 118 | // Forgejo requires a summary when requesting changes, even with inline comments. | |
| 119 | const canSubmit = (verdict: PullRequestReviewVerdict) => | |
| 120 | verdict === "request-changes" && requestChangesSummaryRequired | |
| 121 | ? body.trim().length > 0 | |
| 122 | : verdict === "approve" || body.trim().length > 0 || comments.length > 0; | |
| 123 | ||
| 124 | return ( | |
| 125 | <> | |
| 126 | <Textarea | |
| 127 | ref={textareaRef} | |
| 128 | rows={3} | |
| 129 | className="[&_textarea]:max-h-64" | |
| 130 | value={body} | |
| 131 | placeholder={ | |
| 132 | requestChangesSummaryRequired && verdicts.includes("request-changes") | |
| 133 | ? "Summarize your review (required to request changes)" | |
| 134 | : "Summarize your review (optional)" | |
| 135 | } | |
| 136 | aria-label="Review summary" | |
| 137 | onChange={(event) => setSummary(reviewKey, event.target.value)} | |
| 138 | /> | |
| 139 | <div className="mt-2 flex justify-between gap-2"> | |
| 140 | <Select | |
| 141 | value={selectedVerdict?.value ?? null} | |
| 142 | disabled={pending} | |
| 143 | onValueChange={(value) => { | |
| 144 | if (value !== null) setRequestedVerdict(value); | |
| 145 | }} | |
| 146 | > | |
| 147 | <SelectTrigger size="xs" className="w-auto min-w-0" aria-label="Review verdict"> | |
| 148 | <span className="flex items-center gap-1.5"> | |
| 149 | {selectedVerdict?.icon} | |
| 150 | {selectedVerdict?.label} | |
| 151 | </span> | |
| 152 | </SelectTrigger> | |
| 153 | <SelectPopup side="top" alignItemWithTrigger={false}> | |
| 154 | {offered.map((verdict) => ( | |
| 155 | <SelectItem key={verdict.value} value={verdict.value}> | |
| 156 | <span className="flex items-center gap-1.5"> | |
| 157 | {verdict.icon} | |
| 158 | {verdict.label} | |
| 159 | </span> | |
| 160 | </SelectItem> | |
| 161 | ))} | |
| 162 | </SelectPopup> | |
| 163 | </Select> | |
| 164 | <Button | |
| 165 | size="xs" | |
| 166 | disabled={pending || selectedVerdict === undefined || !canSubmit(selectedVerdict.value)} | |
| 167 | onClick={() => { | |
| 168 | if (selectedVerdict !== undefined) void submit(selectedVerdict); | |
| 169 | }} | |
| 170 | > | |
| 171 | {pending ? "Submitting..." : "Submit review"} | |
| 172 | </Button> | |
| 173 | </div> | |
| 174 | </> | |
| 175 | ); | |
| 176 | } |
apps/web/src/components/ui/popover.tsx+8-3| ... | ... | @@ -25,6 +25,7 @@ function PopoverPopup({ |
| 25 | 25 | sideOffset = 4, |
| 26 | 26 | alignOffset = 0, |
| 27 | 27 | tooltipStyle = false, |
| 28 | keepMounted = false, | |
| 28 | 29 | anchor, |
| 29 | 30 | ...props |
| 30 | 31 | }: PopoverPrimitive.Popup.Props & { |
| ... | ... | @@ -34,10 +35,14 @@ function PopoverPopup({ |
| 34 | 35 | sideOffset?: PopoverPrimitive.Positioner.Props["sideOffset"]; |
| 35 | 36 | alignOffset?: PopoverPrimitive.Positioner.Props["alignOffset"]; |
| 36 | 37 | tooltipStyle?: boolean; |
| 38 | keepMounted?: PopoverPrimitive.Portal.Props["keepMounted"]; | |
| 37 | 39 | anchor?: PopoverPrimitive.Positioner.Props["anchor"]; |
| 38 | 40 | }) { |
| 41 | // Viewport rekeys its children when the active trigger clears on close. Persistent | |
| 42 | // single-trigger forms need a stable container to retain drafts and submit guards. | |
| 43 | const Viewport = keepMounted ? "div" : PopoverPrimitive.Viewport; | |
| 39 | 44 | return ( |
| 40 | <PopoverPrimitive.Portal> | |
| 45 | <PopoverPrimitive.Portal keepMounted={keepMounted}> | |
| 41 | 46 | <PopoverPrimitive.Positioner |
| 42 | 47 | align={align} |
| 43 | 48 | alignOffset={alignOffset} |
| ... | ... | @@ -59,7 +64,7 @@ function PopoverPopup({ |
| 59 | 64 | data-slot="popover-popup" |
| 60 | 65 | {...props} |
| 61 | 66 | > |
| 62 | <PopoverPrimitive.Viewport | |
| 67 | <Viewport | |
| 63 | 68 | className={cn( |
| 64 | 69 | "relative size-full max-h-(--available-height) overflow-clip px-(--viewport-inline-padding) py-4 [--viewport-inline-padding:--spacing(4)] has-data-[slot=calendar]:p-2 data-instant:transition-none **:data-current:data-ending-style:opacity-0 **:data-current:data-starting-style:opacity-0 **:data-previous:data-ending-style:opacity-0 **:data-previous:data-starting-style:opacity-0 **:data-current:w-[calc(var(--popup-width)-2*var(--viewport-inline-padding)-2px)] **:data-previous:w-[calc(var(--popup-width)-2*var(--viewport-inline-padding)-2px)] **:data-current:opacity-100 **:data-previous:opacity-100 **:data-current:transition-opacity **:data-previous:transition-opacity", |
| 65 | 70 | tooltipStyle |
| ... | ... | @@ -70,7 +75,7 @@ function PopoverPopup({ |
| 70 | 75 | data-slot="popover-viewport" |
| 71 | 76 | > |
| 72 | 77 | {children} |
| 73 | </PopoverPrimitive.Viewport> | |
| 78 | </Viewport> | |
| 74 | 79 | </PopoverPrimitive.Popup> |
| 75 | 80 | </PopoverPrimitive.Positioner> |
| 76 | 81 | </PopoverPrimitive.Portal> |