1/**
2 * The Pull requests tab (`/:owner/:repo/pulls`): GitHub-style list with
3 * open/closed/merged filter tabs and a "New pull request" form
4 * (base/head branch selectors from the refs advertisement) for
5 * signed-in users.
6 *
7 * Deliberately free of `@pierre/diffs` imports — this module is statically
8 * imported by `Repo.tsx` and lives in the initial bundle; the diff-heavy
9 * detail page (`Pull.tsx`) is a separate lazy chunk.
10 */
11import { useEffect, useState } from "react";
12import { createPull, listPulls, type Pull, type PullState } from "../client.ts";
13import {
14 Button,
15 ErrorBox,
16 GitMergeIcon,
17 Input,
18 PullClosedIcon,
19 PullRequestIcon,
20 Spinner,
21} from "../components.tsx";
22import { timeAgo } from "../format.ts";
23import { href, Link, useRouter } from "../router.tsx";
24import type { RepoContext } from "./Repo.tsx";
25
26// ── shared PR presentation atoms (also used by Pull.tsx) ────────────────────
27
28/** `refs/heads/main` → `main` (display only; API calls keep full names). */
29export const shortRef = (ref: string): string =>
30 ref.replace(/^refs\/(heads|tags)\//, "");
31
32/** The octicon for a PR state (uncolored — callers pick the tone). */
33export const PullStateGlyph = ({
34 state,
35 className,
36}: {
37 state: PullState;
38 className?: string;
39}) =>
40 state === "open" ? (
41 <PullRequestIcon className={className} />
42 ) : state === "merged" ? (
43 <GitMergeIcon className={className} />
44 ) : (
45 <PullClosedIcon className={className} />
46 );
47
48/** Foreground tone matching a PR state (list rows, inline glyphs). */
49export const stateTone: Record<PullState, string> = {
50 open: "text-success",
51 merged: "text-done",
52 closed: "text-danger",
53};
54
55/** GitHub-style filled state pill: green Open, purple Merged, red Closed. */
56export const PullStateBadge = ({ state }: { state: PullState }) => (
57 <span
58 className={`inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium text-fg-on-emphasis ${
59 state === "open"
60 ? "bg-success-emphasis"
61 : state === "merged"
62 ? "bg-done-emphasis"
63 : "bg-danger-emphasis"
64 }`}
65 >
66 <PullStateGlyph state={state} className="size-3.5" />
67 {state === "open" ? "Open" : state === "merged" ? "Merged" : "Closed"}
68 </span>
69);
70
71// ── list row ────────────────────────────────────────────────────────────────
72
73const PullRow = ({ context, pull }: { context: RepoContext; pull: Pull }) => {
74 const { owner, name } = context.repo;
75 return (
76 <li className="flex items-start gap-3 border-b border-border-muted px-4 py-3 last:border-b-0 hover:bg-canvas-subtle">
77 <PullStateGlyph
78 state={pull.state}
79 className={`mt-0.5 shrink-0 ${stateTone[pull.state]}`}
80 />
81 <div className="min-w-0 grow">
82 <Link
83 to={href(owner, name, "pulls", String(pull.number))}
84 className="text-sm font-semibold hover:text-accent hover:underline"
85 >
86 {pull.title}
87 </Link>
88 <p className="mt-0.5 text-xs text-fg-muted">
89 #{pull.number}{" "}
90 {pull.state === "merged"
91 ? `merged ${timeAgo(pull.mergedAt ?? pull.updatedAt)}`
92 : pull.state === "closed"
93 ? `closed ${timeAgo(pull.updatedAt)}`
94 : `opened ${timeAgo(pull.createdAt)}`}{" "}
95 · <code className="font-mono">{shortRef(pull.baseRef)}</code>
96 {" ← "}
97 <code className="font-mono">{shortRef(pull.headRef)}</code>
98 </p>
99 </div>
100 </li>
101 );
102};
103
104// ── new pull request form ───────────────────────────────────────────────────
105
106const BranchSelect = ({
107 label,
108 value,
109 onChange,
110 branches,
111}: {
112 label: string;
113 value: string;
114 onChange: (value: string) => void;
115 branches: string[];
116}) => (
117 <label className="inline-flex items-center gap-1.5 rounded-md border border-border-muted bg-canvas px-2.5 py-1.5 text-sm">
118 <span className="text-fg-muted">{label}:</span>
119 <select
120 value={value}
121 onChange={(event) => onChange(event.target.value)}
122 className="cursor-pointer appearance-none bg-transparent font-medium outline-none"
123 >
124 {branches.map((branch) => (
125 <option key={branch} value={branch}>
126 {branch}
127 </option>
128 ))}
129 </select>
130 </label>
131);
132
133const NewPullForm = ({
134 context,
135 onCreated,
136}: {
137 context: RepoContext;
138 onCreated: (pull: Pull) => void;
139}) => {
140 const branches = context.refs.refs
141 .filter((ref) => ref.name.startsWith("refs/heads/"))
142 .map((ref) => shortRef(ref.name));
143 const defaultBase = branches.includes(context.repo.defaultBranch)
144 ? context.repo.defaultBranch
145 : (branches[0] ?? "");
146 const [base, setBase] = useState(defaultBase);
147 const [head, setHead] = useState(
148 branches.find((branch) => branch !== defaultBase) ?? defaultBase,
149 );
150 const [title, setTitle] = useState("");
151 const [body, setBody] = useState("");
152 const [error, setError] = useState<unknown>(null);
153 const [busy, setBusy] = useState(false);
154 const { connection, repo } = context;
155
156 const submit = async () => {
157 setBusy(true);
158 setError(null);
159 try {
160 const pull = await createPull(connection, repo.owner, repo.name, {
161 title: title.trim(),
162 base,
163 head,
164 ...(body.trim() ? { body: body.trim() } : {}),
165 });
166 onCreated(pull);
167 } catch (cause) {
168 setError(cause);
169 } finally {
170 setBusy(false);
171 }
172 };
173
174 return (
175 <form
176 className="mb-4 space-y-3 rounded-md border border-border-muted bg-canvas-subtle p-4"
177 onSubmit={(event) => {
178 event.preventDefault();
179 void submit();
180 }}
181 >
182 <div className="flex flex-wrap items-center gap-2 text-sm">
183 <BranchSelect
184 label="base"
185 value={base}
186 onChange={setBase}
187 branches={branches}
188 />
189 <span className="text-fg-muted">←</span>
190 <BranchSelect
191 label="compare"
192 value={head}
193 onChange={setHead}
194 branches={branches}
195 />
196 {head === base && (
197 <span className="text-xs text-attention">
198 choose two different branches
199 </span>
200 )}
201 </div>
202 <Input value={title} onChange={setTitle} placeholder="Title" />
203 <textarea
204 value={body}
205 onChange={(event) => setBody(event.target.value)}
206 placeholder="Description (optional, Markdown)"
207 rows={5}
208 className="w-full rounded-md border border-border-muted bg-canvas px-3 py-1.5 text-sm outline-none focus:border-accent focus:ring-2 focus:ring-accent/30"
209 />
210 {error != null && <ErrorBox error={error} />}
211 <Button
212 kind="primary"
213 type="submit"
214 disabled={busy || title.trim().length === 0 || head === base}
215 >
216 {busy ? "Creating…" : "Create pull request"}
217 </Button>
218 </form>
219 );
220};
221
222// ── the tab ─────────────────────────────────────────────────────────────────
223
224const FILTERS: PullState[] = ["open", "closed", "merged"];
225
226export const PullsTab = ({ context }: { context: RepoContext }) => {
227 const [filter, setFilter] = useState<PullState>("open");
228 const [pulls, setPulls] = useState<Pull[] | null>(null);
229 const [cursor, setCursor] = useState<string | null>(null);
230 const [error, setError] = useState<unknown>(null);
231 const [showNew, setShowNew] = useState(false);
232 const { navigate } = useRouter();
233 const { connection, repo } = context;
234 const signedIn = context.user !== null;
235
236 const load = async (state: PullState, nextCursor?: string) => {
237 try {
238 const page = await listPulls(connection, repo.owner, repo.name, {
239 state,
240 limit: 50,
241 ...(nextCursor ? { cursor: nextCursor } : {}),
242 });
243 setPulls((existing) =>
244 nextCursor ? [...(existing ?? []), ...page.items] : page.items,
245 );
246 setCursor(page.hasMore ? page.nextCursor : null);
247 } catch (cause) {
248 setError(cause);
249 }
250 };
251
252 useEffect(() => {
253 setPulls(null);
254 setError(null);
255 void load(filter);
256 // eslint-disable-next-line react-hooks/exhaustive-deps
257 }, [filter, repo.repoId]);
258
259 if (error != null) return <ErrorBox error={error} />;
260
261 return (
262 <div>
263 <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
264 <div className="flex items-center gap-1 rounded-md border border-border-muted p-0.5">
265 {FILTERS.map((state) => (
266 <button
267 key={state}
268 type="button"
269 onClick={() => setFilter(state)}
270 className={`inline-flex cursor-pointer items-center gap-1.5 rounded px-3 py-1 text-sm capitalize ${
271 filter === state
272 ? "bg-canvas-subtle font-semibold"
273 : "text-fg-muted hover:text-fg-default"
274 }`}
275 >
276 <PullStateGlyph
277 state={state}
278 className={`size-3.5 ${filter === state ? stateTone[state] : ""}`}
279 />
280 {state}
281 </button>
282 ))}
283 </div>
284 {signedIn && (
285 <Button kind="primary" onClick={() => setShowNew((value) => !value)}>
286 New pull request
287 </Button>
288 )}
289 </div>
290
291 {showNew && (
292 <NewPullForm
293 context={context}
294 onCreated={(pull) =>
295 navigate(href(repo.owner, repo.name, "pulls", String(pull.number)))
296 }
297 />
298 )}
299
300 {pulls === null ? (
301 <Spinner />
302 ) : (
303 <>
304 <ul className="overflow-hidden rounded-md border border-border-muted">
305 {pulls.map((pull) => (
306 <PullRow key={pull.number} context={context} pull={pull} />
307 ))}
308 {pulls.length === 0 && (
309 <li className="px-4 py-12 text-center text-sm text-fg-muted">
310 No {filter} pull requests
311 {filter === "open" && signedIn && " — create one above."}
312 </li>
313 )}
314 </ul>
315 {cursor !== null && (
316 <div className="mt-4 flex justify-center">
317 <Button onClick={() => void load(filter, cursor)}>
318 Load more
319 </Button>
320 </div>
321 )}
322 </>
323 )}
324 </div>
325 );
326};