1import type { PullRequestAction } from "@t3tools/contracts";
2import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
3import { SearchIcon } from "lucide-react";
4import { PullRequestStackPopover } from "./PullRequestStackPopover";
5import {
6 PullRequestSpeedActions,
7 type PullRequestSpeedActionResult,
8} from "./PullRequestSpeedActions";
9import { memo, type RefCallback } from "react";
10
11import { cn } from "~/lib/utils";
12import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
13
14import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
15import { PullRequestChecksPopover } from "./PullRequestChecksPopover";
16import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic";
17import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu";
18import {
19 PULL_REQUEST_ROW_CLASS,
20 PULL_REQUEST_ROW_NUMBER_CLASS,
21 PullRequestRowAuthor,
22 PullRequestRowGlyph,
23 PullRequestRowLines,
24} from "./PullRequestListRow";
25import {
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.
37const LABEL_SLOTS = [
38 { overflow: "@xl/pr-row-meta:hidden" },
39 { overflow: "@3xl/pr-row-meta:hidden" },
40 { overflow: "" },
41] as const;
42
43function 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 */
77const PAGE_ROW_CLASS = "px-3 py-2.5";
78
79export type PullRequestRowTarget = Pick<
80 EnvironmentPullRequestEntry,
81 "environmentId" | "projectId" | "host" | "repository" | "number"
82>;
83
84function 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 */
283export const PullRequestRow = memo(PullRequestRowImpl);