1import type {
2 PullRequestActor,
3 PullRequestMergeability,
4 PullRequestState,
5} from "@t3tools/contracts";
6import type { ReactNode } from "react";
7
8import { cn } from "~/lib/utils";
9import { formatRelativeTimeLabel } from "~/timestampFormat";
10
11import { MiddleTruncate } from "../ui/middle-truncate";
12import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
13import {
14 PullRequestActorAvatar,
15 PullRequestConflictGlyph,
16 PullRequestStateGlyph,
17} from "./pullRequestPresentation";
18
19/**
20 * The one row shape both pull request lists share: the full page and a thread's linked panel.
21 * A status column, then two lines — number and title with the diff counts on the right;
22 * author and whatever else the caller shows under them with the time on the right. The column
23 * stacks the lifecycle glyph over the checks glyph, so what a pull request is and how it is
24 * doing read top to bottom at the left edge, and the right edge is only numbers and time. The
25 * page puts repository and labels there, the panel puts the branches there. The caller owns
26 * the wrapper (a link on the panel, a button on the page) and hands in the slots.
27 */
28export const PULL_REQUEST_ROW_CLASS =
29 "group/pr-row flex w-full items-center gap-2 rounded-md py-1 pr-1 text-left";
30
31export const PULL_REQUEST_ROW_NUMBER_CLASS =
32 "shrink-0 font-mono text-xs tabular-nums text-muted-foreground";
33
34/**
35 * The conflict warning rides the corner of the lifecycle glyph, over the arrow's merge circle,
36 * so the leading slot stays one icon wide and titles line up whether or not a row is blocked.
37 * The background fill cuts it out of the glyph beneath.
38 */
39export function PullRequestRowGlyph({
40 state,
41 isDraft,
42 mergeability,
43 baseBranch,
44 below,
45 className,
46}: {
47 state: PullRequestState;
48 isDraft: boolean;
49 mergeability?: PullRequestMergeability | undefined;
50 baseBranch?: string | undefined;
51 /** Under the lifecycle glyph, level with the second line: the checks glyph. */
52 below?: ReactNode;
53 className?: string;
54}) {
55 return (
56 <span className={cn("flex w-4 shrink-0 flex-col items-center gap-0.5", className)}>
57 <span className="relative inline-flex">
58 <PullRequestStateGlyph state={state} isDraft={isDraft} />
59 {/* The wrapper takes the offset, not the icon, so the tooltip trigger inside keeps the
60 badge's size and anchors the popup to it. */}
61 <span className="absolute -right-1 -bottom-1 inline-flex">
62 <PullRequestConflictGlyph
63 state={state}
64 isDraft={isDraft}
65 {...(mergeability === undefined ? {} : { mergeability })}
66 {...(baseBranch === undefined ? {} : { baseBranch })}
67 className="size-3 fill-background [stroke-width:2.5]"
68 />
69 </span>
70 </span>
71 {below ? <span className="inline-flex text-2xs">{below}</span> : null}
72 </span>
73 );
74}
75
76export function PullRequestRowLines({
77 number,
78 title,
79 status,
80 signals,
81 meta,
82 metaClassName,
83 updatedAt,
84}: {
85 /** The `#n` reference, already wrapped in whatever tooltip or menu the caller wants on it. */
86 number: ReactNode;
87 title: ReactNode;
88 /** Right end of the first line: stack, diff counts. */
89 status?: ReactNode;
90 /** Right after the title text: checks and review verdict glyphs. */
91 signals?: ReactNode;
92 /** Left of the second line: author, then repository and labels or the branches. */
93 meta?: ReactNode;
94 metaClassName?: string;
95 updatedAt?: string | null | undefined;
96}) {
97 return (
98 <span className="min-w-0 flex-1">
99 <span className="flex min-w-0 items-center gap-1.5">
100 {number}
101 <span className="min-w-0 truncate text-sm">{title}</span>
102 {signals ? (
103 <span className="flex shrink-0 items-center gap-1 text-2xs">{signals}</span>
104 ) : null}
105 {status ? (
106 <span className="ml-auto flex shrink-0 items-center gap-1.5 text-2xs">{status}</span>
107 ) : null}
108 </span>
109 <span
110 className={cn(
111 "flex min-w-0 items-center gap-1.5 overflow-hidden text-2xs text-muted-foreground",
112 metaClassName,
113 )}
114 >
115 {meta}
116 {updatedAt ? (
117 <span className="ml-auto shrink-0 whitespace-nowrap tabular-nums">
118 {formatRelativeTimeLabel(updatedAt)}
119 </span>
120 ) : null}
121 </span>
122 </span>
123 );
124}
125
126/** Avatar and login at the meta line's own size, with the actor's full name on hover. */
127export function PullRequestRowAuthor({
128 actor,
129 className,
130 labelClassName,
131}: {
132 actor: PullRequestActor | null;
133 className?: string;
134 labelClassName?: string;
135}) {
136 const login = actor?.login ?? "ghost";
137 return (
138 <Tooltip>
139 <TooltipTrigger
140 render={<span className={cn("inline-flex min-w-0 items-center gap-1", className)} />}
141 >
142 <PullRequestActorAvatar actor={actor} className="size-3.5" />
143 <span className={cn("truncate", labelClassName)}>{login}</span>
144 </TooltipTrigger>
145 <TooltipPopup side="top">
146 {actor?.name && actor.name !== login ? `${actor.name} (@${login})` : login}
147 </TooltipPopup>
148 </Tooltip>
149 );
150}
151
152/**
153 * `head → base`, in the mono the branches are typed in, each cut in the middle when the row is
154 * short of room. The base keeps its width up to a share of the line, so a long head cannot
155 * squeeze a short `main` out; the arrow stays readable so the two are not read as one name.
156 */
157export function PullRequestRowBranches({ head, base }: { head: string; base: string }) {
158 return (
159 <span className="flex min-w-0 items-center gap-1 font-mono">
160 <MiddleTruncate value={head} />
161 <span className="shrink-0">→</span>
162 <MiddleTruncate value={base} className="max-w-[45%] shrink-0" />
163 </span>
164 );
165}