| 1 | import type { PullRequestAction } from "@t3tools/contracts"; |
| 2 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; |
| 3 | import { SearchIcon } from "lucide-react"; |
| 4 | import { PullRequestStackPopover } from "./PullRequestStackPopover"; |
| 5 | import { |
| 6 | PullRequestSpeedActions, |
| 7 | type PullRequestSpeedActionResult, |
| 8 | } from "./PullRequestSpeedActions"; |
| 9 | import { memo, type RefCallback } from "react"; |
| 10 | |
| 11 | import { cn } from "~/lib/utils"; |
| 12 | import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; |
| 13 | |
| 14 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 15 | import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; |
| 16 | import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic"; |
| 17 | import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; |
| 18 | import { |
| 19 | PULL_REQUEST_ROW_CLASS, |
| 20 | PULL_REQUEST_ROW_NUMBER_CLASS, |
| 21 | PullRequestRowAuthor, |
| 22 | PullRequestRowGlyph, |
| 23 | PullRequestRowLines, |
| 24 | } from "./PullRequestListRow"; |
| 25 | import { |
| 26 | PullRequestDiffStat, |
| 27 | PullRequestLabelChip, |
| 28 | PullRequestReviewDecisionGlyph, |
| 29 | } from "./pullRequestPresentation"; |
| 30 | |
| 31 | /** |
| 32 | * Each slot past the first only appears once the meta line is wide enough to hold it, so a |
| 33 | * narrow row shows one label and a "+N" while a wide one spreads out up to three. The "+N" |
| 34 | * rides on whichever pill is the last visible one, and is hidden as soon as the next slot shows. |
| 35 | */ |
| 36 | // Each slot's pill appears at a wider row, and its overflow count yields to the next slot. |
| 37 | const LABEL_SLOTS = [ |
| 38 | { overflow: "@xl/pr-row-meta:hidden" }, |
| 39 | { overflow: "@3xl/pr-row-meta:hidden" }, |
| 40 | { overflow: "" }, |
| 41 | ] as const; |
| 42 | |
| 43 | function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry["labels"] }) { |
| 44 | if (labels.length === 0) return null; |
| 45 | return ( |
| 46 | <span className="flex min-w-0 items-center gap-1"> |
| 47 | {LABEL_SLOTS.map((slot, index) => { |
| 48 | const label = labels[index]; |
| 49 | if (!label) return null; |
| 50 | const remaining = labels.length - index - 1; |
| 51 | return ( |
| 52 | <PullRequestLabelChip |
| 53 | key={label.name} |
| 54 | label={label} |
| 55 | className={ |
| 56 | index === 0 |
| 57 | ? "" |
| 58 | : index === 1 |
| 59 | ? "hidden @xl/pr-row-meta:inline-flex" |
| 60 | : "hidden @3xl/pr-row-meta:inline-flex" |
| 61 | } |
| 62 | > |
| 63 | {remaining > 0 ? ( |
| 64 | <span className={cn("shrink-0", slot.overflow)}>+{remaining}</span> |
| 65 | ) : null} |
| 66 | </PullRequestLabelChip> |
| 67 | ); |
| 68 | })} |
| 69 | </span> |
| 70 | ); |
| 71 | } |
| 72 | |
| 73 | /** |
| 74 | * The page row keeps a little more room around the shared lines than the panel, which sits in |
| 75 | * a narrow column. Its outer wrapper reserves the full row height when offscreen. |
| 76 | */ |
| 77 | const PAGE_ROW_CLASS = "px-3 py-2.5"; |
| 78 | |
| 79 | export type PullRequestRowTarget = Pick< |
| 80 | EnvironmentPullRequestEntry, |
| 81 | "environmentId" | "projectId" | "host" | "repository" | "number" |
| 82 | >; |
| 83 | |
| 84 | function PullRequestRowImpl({ |
| 85 | entry, |
| 86 | selected, |
| 87 | showProjectTitle, |
| 88 | showProvider, |
| 89 | environmentLabel, |
| 90 | matchedElsewhere, |
| 91 | statsKey, |
| 92 | statsRef, |
| 93 | onSelect, |
| 94 | speedMode, |
| 95 | hostActions, |
| 96 | onActed, |
| 97 | closing = false, |
| 98 | sweeping = false, |
| 99 | onCloseSweepStart, |
| 100 | }: { |
| 101 | entry: EnvironmentPullRequestEntry; |
| 102 | selected: boolean; |
| 103 | showProjectTitle: boolean; |
| 104 | /** Only when the list spans more than one host, where the repository alone is ambiguous. */ |
| 105 | showProvider: boolean; |
| 106 | /** Names the server this row was read from, where the list spans more than one. */ |
| 107 | environmentLabel?: string; |
| 108 | /** |
| 109 | * A search found this, but in something the row does not show — a description, a comment, a |
| 110 | * commit message. Saying so is the difference between a result and an apparently random row. |
| 111 | */ |
| 112 | matchedElsewhere?: boolean; |
| 113 | /** Used by the list's shared visibility observer to defer optional line-count reads. */ |
| 114 | statsKey?: string; |
| 115 | statsRef?: RefCallback<HTMLDivElement>; |
| 116 | onSelect: (entry: PullRequestRowTarget) => void; |
| 117 | speedMode: boolean; |
| 118 | /** What this row's host can do, which is what speed mode offers. */ |
| 119 | hostActions: ReadonlySet<PullRequestAction>; |
| 120 | onActed: (result: PullRequestSpeedActionResult) => void; |
| 121 | closing?: boolean; |
| 122 | sweeping?: boolean; |
| 123 | onCloseSweepStart?: (entry: EnvironmentPullRequestEntry, event: PointerEvent) => void; |
| 124 | }) { |
| 125 | const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider); |
| 126 | return ( |
| 127 | <div |
| 128 | ref={statsRef} |
| 129 | data-pull-request-stats-key={statsKey} |
| 130 | data-pull-request-key={statsKey} |
| 131 | className={cn( |
| 132 | // Keep the action buttons inside the same skipped row, but outside its selection button. |
| 133 | "flex items-center rounded-md [content-visibility:auto] [contain-intrinsic-block-size:56.5px]", |
| 134 | selected ? "bg-accent" : "hover:bg-accent/60", |
| 135 | )} |
| 136 | > |
| 137 | <button |
| 138 | type="button" |
| 139 | aria-current={selected ? "true" : undefined} |
| 140 | onClick={() => onSelect(entry)} |
| 141 | className={cn( |
| 142 | PULL_REQUEST_ROW_CLASS, |
| 143 | PAGE_ROW_CLASS, |
| 144 | "min-w-0 flex-1 cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring", |
| 145 | )} |
| 146 | > |
| 147 | <PullRequestRowGlyph |
| 148 | state={entry.state} |
| 149 | isDraft={entry.isDraft} |
| 150 | mergeability={entry.mergeability} |
| 151 | baseBranch={entry.baseBranch} |
| 152 | // On the title line rather than between the lines, as main aligns it. |
| 153 | className="mt-0.75 self-start" |
| 154 | /> |
| 155 | <PullRequestRowLines |
| 156 | number={ |
| 157 | // The number carries the link, here as much as on the detail: a right-click on it |
| 158 | // copies the pull request's own address rather than opening the editing menu. |
| 159 | <span |
| 160 | className={PULL_REQUEST_ROW_NUMBER_CLASS} |
| 161 | onContextMenu={(event) => { |
| 162 | event.preventDefault(); |
| 163 | event.stopPropagation(); |
| 164 | void showPullRequestLinkContextMenu({ |
| 165 | url: entry.url, |
| 166 | openLabel: openOnHostLabel(sourceControlClients.get(entry.provider)), |
| 167 | position: { x: event.clientX, y: event.clientY }, |
| 168 | }); |
| 169 | }} |
| 170 | > |
| 171 | #{entry.number} |
| 172 | </span> |
| 173 | } |
| 174 | title={entry.title} |
| 175 | signals={ |
| 176 | <> |
| 177 | {entry.checksState === undefined ? null : ( |
| 178 | <PullRequestChecksPopover |
| 179 | checksState={entry.checksState} |
| 180 | environmentId={entry.environmentId} |
| 181 | reference={{ |
| 182 | projectId: entry.projectId, |
| 183 | repository: entry.repository, |
| 184 | number: entry.number, |
| 185 | }} |
| 186 | /> |
| 187 | )} |
| 188 | {/* Only a verdict the host actually reports: an approval, a request for changes, |
| 189 | or a review the branch rules still require. No glyph on the common case of a |
| 190 | pull request nobody has reviewed, so a row only wears a person when the person |
| 191 | has said something. */} |
| 192 | {entry.reviewDecision === undefined ? null : ( |
| 193 | <PullRequestReviewDecisionGlyph decision={entry.reviewDecision} /> |
| 194 | )} |
| 195 | </> |
| 196 | } |
| 197 | status={ |
| 198 | <> |
| 199 | {entry.stack ? ( |
| 200 | <PullRequestStackPopover |
| 201 | environmentId={entry.environmentId} |
| 202 | reference={{ |
| 203 | projectId: entry.projectId, |
| 204 | host: entry.host, |
| 205 | repository: entry.repository, |
| 206 | number: entry.number, |
| 207 | }} |
| 208 | membership={entry.stack} |
| 209 | onSelect={(target) => |
| 210 | onSelect({ ...target, host: entry.host, environmentId: entry.environmentId }) |
| 211 | } |
| 212 | /> |
| 213 | ) : null} |
| 214 | <PullRequestDiffStat |
| 215 | additions={entry.additions} |
| 216 | deletions={entry.deletions} |
| 217 | className="font-mono" |
| 218 | /> |
| 219 | </> |
| 220 | } |
| 221 | metaClassName="@container/pr-row-meta" |
| 222 | meta={ |
| 223 | <> |
| 224 | {matchedElsewhere ? ( |
| 225 | <Tooltip> |
| 226 | <TooltipTrigger |
| 227 | render={ |
| 228 | <span className="flex min-w-6 items-center gap-1 overflow-hidden rounded-full border border-border/60 px-1 text-3xs" /> |
| 229 | } |
| 230 | > |
| 231 | <span className="sr-only">matched in the description</span> |
| 232 | <SearchIcon aria-hidden className="size-3 shrink-0" /> |
| 233 | <span aria-hidden className="hidden truncate @xs/pr-row-meta:block"> |
| 234 | matched in the description |
| 235 | </span> |
| 236 | </TooltipTrigger> |
| 237 | <TooltipPopup side="top">Matched in the description</TooltipPopup> |
| 238 | </Tooltip> |
| 239 | ) : null} |
| 240 | {showProvider ? ( |
| 241 | <Tooltip> |
| 242 | <TooltipTrigger render={<span className="inline-flex shrink-0" />}> |
| 243 | <Icon aria-label={providerName} className="size-3" /> |
| 244 | </TooltipTrigger> |
| 245 | <TooltipPopup>{providerName}</TooltipPopup> |
| 246 | </Tooltip> |
| 247 | ) : null} |
| 248 | <PullRequestRowAuthor |
| 249 | actor={entry.author} |
| 250 | className="min-w-3.5 max-w-40" |
| 251 | labelClassName="sr-only @xs/pr-row-meta:not-sr-only @xs/pr-row-meta:truncate" |
| 252 | /> |
| 253 | {showProjectTitle ? <span className="truncate">{entry.repository}</span> : null} |
| 254 | {environmentLabel ? ( |
| 255 | <span className="min-w-0 max-w-32 truncate">{environmentLabel}</span> |
| 256 | ) : null} |
| 257 | {entry.labels.length > 0 ? <PullRequestRowLabels labels={entry.labels} /> : null} |
| 258 | </> |
| 259 | } |
| 260 | updatedAt={entry.updatedAt} |
| 261 | /> |
| 262 | </button> |
| 263 | {hostActions.size > 0 ? ( |
| 264 | <PullRequestSpeedActions |
| 265 | entry={entry} |
| 266 | hostActions={hostActions} |
| 267 | visible={speedMode} |
| 268 | onActed={onActed} |
| 269 | closing={closing} |
| 270 | sweeping={sweeping} |
| 271 | {...(onCloseSweepStart ? { onCloseSweepStart } : {})} |
| 272 | /> |
| 273 | ) : null} |
| 274 | </div> |
| 275 | ); |
| 276 | } |
| 277 | |
| 278 | /** |
| 279 | * Memoized: the list re-renders on every keystroke of a search and every status poll, and a |
| 280 | * row whose entry, selection and match state are unchanged has nothing new to say. Effective |
| 281 | * because the route hands it a stable `onSelect`. |
| 282 | */ |
| 283 | export const PullRequestRow = memo(PullRequestRowImpl); |