Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

713 linesCodeBlameRaw
1/**
2 * The Branches page's list: each branch with its latest commit's author
3 * and time, its checks, how far it is behind and ahead of the default
4 * branch, and its pull request, and, for people who can push, deleting it
5 * (and restoring it for the rest of the visit), renaming it, and making a
6 * new branch. The page's action (routes/repo/branches.tsx) does the work.
7 */
8import {
9 Activity,
10 Check,
11 Copy,
12 GitBranch,
13 GitBranchPlus,
14 GitPullRequestArrow,
15 Globe,
16 LoaderCircle,
17 MoreHorizontal,
18 PenLine,
19 ShieldCheck,
20 ShieldAlert,
21 Trash2,
22 Undo2,
23 X,
24} from "lucide-react";
25import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
26import { Link, useFetcher } from "react-router";
27
28import type { ActiveBranch } from "./branches";
29import type { ChecksSource } from "./commit-checks";
30import { CommitChecksBadge } from "./commit-checks";
31import { CommitAvatars } from "./commit-person";
32import { host } from "./deploy";
33import { CopyLine, TimeAgo } from "./ui";
34import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
35import {
36 AlertDialog,
37 AlertDialogAction,
38 AlertDialogCancel,
39 AlertDialogContent,
40 AlertDialogDescription,
41 AlertDialogFooter,
42 AlertDialogHeader,
43 AlertDialogTitle,
44} from "./ui/alert-dialog";
45import { Badge } from "./ui/badge";
46import { Button } from "./ui/button";
47import { Combobox } from "./ui/combobox";
48import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog";
49import {
50 DropdownMenu,
51 DropdownMenuContent,
52 DropdownMenuItem,
53 DropdownMenuSeparator,
54 DropdownMenuTrigger,
55} from "./ui/dropdown-menu";
56import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field";
57import { Hint } from "./ui/hint";
58import { Input } from "./ui/input";
59import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";
60import { PullIcon } from "./work-icons";
61import { isMerged } from "../lib/branches";
62import { commitHint } from "../lib/commit-message";
63
64/** What the page's action answers. */
65export type BranchActionData = { intent: string; branch?: string; hash?: string; error?: string };
66
67/** A row as the list shows it: a branch, or one deleted on this visit. */
68export type BranchRowData = ActiveBranch & { deleted?: boolean };
69
70/** What every row needs to know about the page. */
71export type BranchListContext = {
72 base: string;
73 main: string;
74 checks: ChecksSource;
75 /** The longest behind or ahead count shown: a full bar. */
76 scale: number;
77 /** May push: make, delete, restore and rename branches. */
78 canPush: boolean;
79 /** May rename the default branch (Admin). */
80 canRenameDefault: boolean;
81 /** May see the rules (Admin). */
82 canViewRules: boolean;
83 onDeleted: (row: ActiveBranch) => void;
84 onRestored: (name: string) => void;
85 onError: (message: string) => void;
86};
87
88/** A branch name in an address: each part encoded, its slashes kept. */
89export function refPath(name: string): string {
90 return name.split("/").map(encodeURIComponent).join("/");
91}
92
93/** A small copy button: a check for a moment once copied. */
94function CopyButton({ text, label }: { text: string; label: string }) {
95 const [copied, setCopied] = useState(false);
96 return (
97 <Hint label={copied ? "Copied" : label}>
98 <Button
99 type="button"
100 variant="ghost"
101 size="inline"
102 aria-label={label}
103 className="p-1 text-faint opacity-70 group-hover/row:opacity-100 focus-visible:opacity-100"
104 onClick={() => {
105 void navigator.clipboard.writeText(text).then(() => {
106 setCopied(true);
107 setTimeout(() => setCopied(false), 1500);
108 });
109 }}
110 >
111 {copied ? <Check size={13} className="text-success" /> : <Copy size={13} />}
112 </Button>
113 </Hint>
114 );
115}
116
117/** The branch's name in a code chip, linked to its files, with a copy button. */
118function BranchName({ row, base, isDefault }: { row: BranchRowData; base: string; isDefault: boolean }) {
119 return (
120 <span className="flex min-w-0 items-center gap-1">
121 {row.deleted ? (
122 <code className="truncate rounded-md bg-raised px-1.5 py-0.5 font-mono text-[0.8125rem] text-faint line-through">{row.name}</code>
123 ) : (
124 <Link
125 to={`${base}/tree/${encodeURIComponent(row.name)}`}
126 className="min-w-0 truncate rounded-md bg-accent/10 px-1.5 py-0.5 font-mono text-[0.8125rem] font-medium text-accent hover:bg-accent/20"
127 >
128 {row.name}
129 </Link>
130 )}
131 {!row.deleted && <CopyButton text={row.name} label="Copy the branch name" />}
132 {isDefault && <Badge className="ml-1">default</Badge>}
133 {row.preview && !row.deleted && (
134 <Hint label={`Preview at ${host(row.preview)}`}>
135 <a href={row.preview} aria-label={`Preview at ${host(row.preview)}`} className="ml-1 inline-flex rounded p-0.5 text-accent hover:bg-raised">
136 <Globe size={13} />
137 </a>
138 </Hint>
139 )}
140 </span>
141 );
142}
143
144/** Who made the latest commit, and when, linked to it. */
145function Updated({ row, base }: { row: BranchRowData; base: string }) {
146 if (!row.commit) return <span className="text-faint">—</span>;
147 return (
148 // The avatars link to their people, so they sit beside the commit's
149 // link rather than in it: a link inside a link is split apart by the
150 // browser, and the page then fails to hydrate.
151 <span className="inline-flex items-center gap-1.5 whitespace-nowrap">
152 <CommitAvatars commit={row.commit} size={18} max={2} />
153 <Hint label={commitHint(row.commit.message)}>
154 <Link to={`${base}/commit/${row.commit.hash}`} className="text-muted hover:text-fg">
155 <TimeAgo at={row.commit.at} />
156 </Link>
157 </Hint>
158 </span>
159 );
160}
161
162function driftWords(drift: NonNullable<ActiveBranch["drift"]>, main: string): string {
163 return `${drift.behind} ${drift.behind === 1 ? "commit" : "commits"} behind ${main}, ${drift.ahead} ahead`;
164}
165
166/** Behind on the left, ahead on the right, each with a bar against the longest shown. */
167function DriftBars({ drift, scale, main }: { drift: ActiveBranch["drift"]; scale: number; main: string }) {
168 if (!drift) return <span className="text-faint">—</span>;
169 const width = (count: number) => `${count === 0 ? 0 : Math.max(6, Math.round((count / scale) * 100))}%`;
170 return (
171 <Hint label={driftWords(drift, main)}>
172 <span className="inline-flex w-28 flex-col gap-1" tabIndex={0}>
173 <span className="sr-only">{driftWords(drift, main)}</span>
174 <span className="grid grid-cols-2 font-mono text-xs tabular-nums" aria-hidden>
175 <span className="border-r border-line pr-1.5 text-right text-muted">{drift.behind}</span>
176 <span className="pl-1.5 text-muted">{drift.ahead}</span>
177 </span>
178 <span className="grid h-1 grid-cols-2" aria-hidden>
179 <span className="flex justify-end border-r border-line">
180 <span className="h-full rounded-l-full bg-faint/60" style={{ width: width(drift.behind) }} />
181 </span>
182 <span className="flex">
183 <span className="h-full rounded-r-full bg-accent/70" style={{ width: width(drift.ahead) }} />
184 </span>
185 </span>
186 </span>
187 </Hint>
188 );
189}
190
191const PULL_STATE = { open: "Open", draft: "Draft", merged: "Merged", closed: "Closed" } as const;
192
193/** The branch's pull request: its state and number, linked. */
194function PullLink({ pull, base }: { pull: ActiveBranch["pull"]; base: string }) {
195 if (!pull) return null;
196 return (
197 <Hint label={pull.title}>
198 <Link
199 to={`${base}/pull/${pull.number}`}
200 className="inline-flex items-center gap-1 rounded-md border border-line px-1.5 py-0.5 text-xs whitespace-nowrap text-muted hover:border-line-strong hover:text-fg"
201 >
202 <PullIcon status={pull.status} size={13} />#{pull.number}
203 <span className="sr-only">
204 {PULL_STATE[pull.status]}: {pull.title}
205 </span>
206 </Link>
207 </Hint>
208 );
209}
210
211/**
212 * A fetcher for one row's actions, calling `done` once with each answer
213 * that comes back. It is called as soon as the answer is in, while the
214 * page reloads: a deleted branch's row is gone once it has, and with it
215 * this fetcher.
216 */
217function useBranchAction(done: (data: BranchActionData) => void) {
218 const fetcher = useFetcher<BranchActionData>();
219 const handled = useRef<BranchActionData | undefined>(undefined);
220 const latest = useRef(done);
221 latest.current = done;
222 useEffect(() => {
223 if (fetcher.state === "submitting" || !fetcher.data || handled.current === fetcher.data) return;
224 handled.current = fetcher.data;
225 latest.current(fetcher.data);
226 }, [fetcher.state, fetcher.data]);
227 return fetcher;
228}
229
230/** Renaming a branch, with what it does to pull requests, addresses and clones. */
231function RenameDialog({
232 row,
233 main,
234 open,
235 onOpenChange,
236}: {
237 row: BranchRowData;
238 main: string;
239 open: boolean;
240 onOpenChange: (open: boolean) => void;
241}) {
242 const [to, setTo] = useState(row.name);
243 const [error, setError] = useState<string | null>(null);
244 const fetcher = useBranchAction((data) => {
245 if (data.error) setError(data.error);
246 else onOpenChange(false);
247 });
248 const busy = fetcher.state !== "idle";
249 const next = to.trim();
250 const isDefault = row.name === main;
251 const openPull = row.pull && (row.pull.status === "open" || row.pull.status === "draft") ? row.pull : null;
252 return (
253 <Dialog
254 open={open}
255 onOpenChange={(now) => {
256 if (now) {
257 setTo(row.name);
258 setError(null);
259 }
260 onOpenChange(now);
261 }}
262 >
263 <DialogContent>
264 <fetcher.Form method="post" className="grid gap-4">
265 <DialogHeader>
266 <DialogTitle>Rename {row.name}</DialogTitle>
267 <DialogDescription>
268 {openPull ? (
269 <>
270 Pull request #{openPull.number} from it follows it to the new name, and stays open.{" "}
271 </>
272 ) : (
273 <>Open pull requests from it follow it to the new name. </>
274 )}
275 Addresses that name {row.name} redirect to the new name until a branch called {row.name} is made again.
276 {isDefault && " It stays the default branch, and pull requests into it merge into the new name."}
277 </DialogDescription>
278 </DialogHeader>
279 <input type="hidden" name="intent" value="rename" />
280 <input type="hidden" name="branch" value={row.name} />
281 <Field>
282 <FieldLabel htmlFor={`rename-${row.name}`}>New name</FieldLabel>
283 <Input
284 id={`rename-${row.name}`}
285 name="to"
286 value={to}
287 onChange={(event) => setTo(event.target.value)}
288 maxLength={200}
289 spellCheck={false}
290 autoCapitalize="off"
291 className="font-mono"
292 aria-invalid={error ? true : undefined}
293 autoFocus
294 />
295 {error && <FieldError>{error}</FieldError>}
296 </Field>
297 <div className="grid gap-1.5">
298 <p className="text-xs text-muted">Clones keep the old name until they catch up:</p>
299 <CopyLine
300 text={`git branch -m ${row.name} ${next || "<new-name>"} && git fetch origin && git branch -u origin/${next || "<new-name>"} ${next || "<new-name>"}`}
301 />
302 </div>
303 <DialogFooter>
304 <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
305 Cancel
306 </Button>
307 <Button type="submit" disabled={busy || next === "" || next === row.name}>
308 {busy && <LoaderCircle size={14} className="animate-spin" aria-hidden />}
309 Rename branch
310 </Button>
311 </DialogFooter>
312 </fetcher.Form>
313 </DialogContent>
314 </Dialog>
315 );
316}
317
318/** The ⋯ menu of a row: a pull request, its activity, its rules, renaming it. */
319function RowMenu({ row, context, onRename }: { row: BranchRowData; context: BranchListContext; onRename: () => void }) {
320 const { base, main, canPush, canRenameDefault, canViewRules } = context;
321 const isDefault = row.name === main;
322 const canRename = isDefault ? canRenameDefault : canPush;
323 return (
324 <DropdownMenu>
325 <Hint label="More">
326 <DropdownMenuTrigger asChild>
327 <Button type="button" variant="ghost" size="icon-xs" aria-label={`More for ${row.name}`}>
328 <MoreHorizontal size={15} />
329 </Button>
330 </DropdownMenuTrigger>
331 </Hint>
332 <DropdownMenuContent align="end">
333 {!isDefault && (
334 <DropdownMenuItem asChild>
335 <Link to={`${base}/compare/${refPath(main)}...${refPath(row.name)}`}>
336 <GitPullRequestArrow />
337 New pull request
338 </Link>
339 </DropdownMenuItem>
340 )}
341 <DropdownMenuItem asChild>
342 <Link to={`${base}/activity?branch=${encodeURIComponent(row.name)}`}>
343 <Activity />
344 Activity
345 </Link>
346 </DropdownMenuItem>
347 {canViewRules && (
348 <DropdownMenuItem asChild>
349 <Link to={`${base}/settings/rules?branch=${encodeURIComponent(row.name)}`}>
350 <ShieldCheck />
351 View rules
352 </Link>
353 </DropdownMenuItem>
354 )}
355 {canRename && (
356 <>
357 <DropdownMenuSeparator />
358 <DropdownMenuItem onSelect={onRename}>
359 <PenLine />
360 Rename branch
361 </DropdownMenuItem>
362 </>
363 )}
364 </DropdownMenuContent>
365 </DropdownMenu>
366 );
367}
368
369/** The trash button; branches with work the default branch lacks ask first. */
370function DeleteButton({ row, context }: { row: BranchRowData; context: BranchListContext }) {
371 const [asking, setAsking] = useState(false);
372 const fetcher = useBranchAction((data) => {
373 if (data.error) context.onError(data.error);
374 else context.onDeleted(row);
375 });
376 const busy = fetcher.state !== "idle";
377 const remove = () => fetcher.submit({ intent: "delete", branch: row.name, head: row.head }, { method: "post" });
378 const ahead = row.drift?.ahead ?? null;
379 const openPull = row.pull && (row.pull.status === "open" || row.pull.status === "draft") ? row.pull : null;
380 return (
381 <>
382 <Hint label="Delete branch">
383 <Button
384 type="button"
385 variant="ghost"
386 size="icon-xs"
387 aria-label={`Delete ${row.name}`}
388 disabled={busy}
389 className="hover:text-danger"
390 onClick={() => (isMerged(row) ? remove() : setAsking(true))}
391 >
392 {busy ? <LoaderCircle size={14} className="animate-spin" /> : <Trash2 size={14} />}
393 </Button>
394 </Hint>
395 <AlertDialog open={asking} onOpenChange={setAsking}>
396 <AlertDialogContent>
397 <AlertDialogHeader>
398 <AlertDialogTitle>Delete {row.name}?</AlertDialogTitle>
399 <AlertDialogDescription>
400 {ahead == null
401 ? `It may have commits that are not on ${context.main}.`
402 : `It has ${ahead} ${ahead === 1 ? "commit" : "commits"} that ${ahead === 1 ? "is" : "are"} not on ${context.main}.`}{" "}
403 {openPull && `Pull request #${openPull.number} from it cannot be updated or merged while it is gone. `}
404 You can restore it from this page until you leave it.
405 </AlertDialogDescription>
406 </AlertDialogHeader>
407 <AlertDialogFooter>
408 <AlertDialogCancel>Cancel</AlertDialogCancel>
409 <AlertDialogAction
410 className="border border-danger/40 bg-transparent text-danger hover:border-danger hover:bg-danger/10"
411 onClick={remove}
412 >
413 Delete branch
414 </AlertDialogAction>
415 </AlertDialogFooter>
416 </AlertDialogContent>
417 </AlertDialog>
418 </>
419 );
420}
421
422/** Making a deleted branch again at the commit it pointed to. */
423function RestoreButton({ row, context }: { row: BranchRowData; context: BranchListContext }) {
424 const fetcher = useBranchAction((data) => {
425 if (data.error) context.onError(data.error);
426 else context.onRestored(row.name);
427 });
428 const busy = fetcher.state !== "idle";
429 return (
430 <Button
431 type="button"
432 variant="outline"
433 size="xs"
434 disabled={busy}
435 onClick={() => fetcher.submit({ intent: "restore", branch: row.name, head: row.head }, { method: "post" })}
436 >
437 {busy ? <LoaderCircle size={13} className="animate-spin" /> : <Undo2 size={13} />}
438 Restore
439 </Button>
440 );
441}
442
443/** A row's buttons: restore once deleted; otherwise delete and the menu. */
444function RowActions({ row, context }: { row: BranchRowData; context: BranchListContext }) {
445 const [renaming, setRenaming] = useState(false);
446 if (row.deleted) return context.canPush ? <RestoreButton row={row} context={context} /> : null;
447 const isDefault = row.name === context.main;
448 return (
449 <span className="flex items-center justify-end gap-0.5">
450 {context.canPush && !isDefault && <DeleteButton row={row} context={context} />}
451 <RowMenu row={row} context={context} onRename={() => setRenaming(true)} />
452 {renaming && <RenameDialog row={row} main={context.main} open={renaming} onOpenChange={setRenaming} />}
453 </span>
454 );
455}
456
457/** One branch as a table row; below `md`, its details fold under the name. */
458function BranchRow({ row, context }: { row: BranchRowData; context: BranchListContext }) {
459 const { base, main, checks, scale } = context;
460 const isDefault = row.name === main;
461 const sha = row.commit?.hash ?? row.head;
462 return (
463 <TableRow className={row.deleted ? "group/row bg-raised/30" : "group/row hover:bg-raised/30"}>
464 <TableCell className="max-w-0 py-2.5 align-middle md:w-[40%]">
465 <BranchName row={row} base={base} isDefault={isDefault} />
466 {/* What the other columns say, for a narrow screen. */}
467 <span className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs md:hidden">
468 {row.deleted ? (
469 <Badge tone="danger">Deleted</Badge>
470 ) : (
471 <>
472 <Updated row={row} base={base} />
473 {sha && <CommitChecksBadge checks={checks} sha={sha} className="size-5" />}
474 {row.drift && !isDefault && (
475 <span className="font-mono text-muted tabular-nums">
476 <span className="sr-only">{driftWords(row.drift, main)}</span>
477 <span aria-hidden>
478 ↓{row.drift.behind} ↑{row.drift.ahead}
479 </span>
480 </span>
481 )}
482 <PullLink pull={row.pull} base={base} />
483 </>
484 )}
485 </span>
486 </TableCell>
487 {row.deleted ? (
488 <TableCell colSpan={4} className="align-middle max-md:hidden">
489 <Badge tone="danger">Deleted</Badge>
490 </TableCell>
491 ) : (
492 <>
493 <TableCell className="align-middle max-md:hidden">
494 <Updated row={row} base={base} />
495 </TableCell>
496 <TableCell className="align-middle max-md:hidden">
497 {sha ? <CommitChecksBadge checks={checks} sha={sha} className="size-6" /> : null}
498 </TableCell>
499 <TableCell className="align-middle max-md:hidden">
500 {isDefault ? <span className="text-xs text-faint">Default</span> : <DriftBars drift={row.drift} scale={scale} main={main} />}
501 </TableCell>
502 <TableCell className="align-middle max-md:hidden">
503 <PullLink pull={row.pull} base={base} />
504 </TableCell>
505 </>
506 )}
507 <TableCell className="w-px py-2 pr-2 align-middle whitespace-nowrap">
508 <RowActions row={row} context={context} />
509 </TableCell>
510 </TableRow>
511 );
512}
513
514/** A section's branches as a table under the column names. */
515export function BranchTable({ rows, context, label }: { rows: BranchRowData[]; context: BranchListContext; label: string }) {
516 return (
517 <div className="overflow-hidden rounded-lg border border-line">
518 <Table aria-label={label}>
519 <TableHeader>
520 <TableRow>
521 <TableHead>Branch</TableHead>
522 <TableHead className="max-md:hidden">Updated</TableHead>
523 <TableHead className="max-md:hidden">Check status</TableHead>
524 <TableHead className="max-md:hidden">
525 <span className="inline-grid w-28 grid-cols-2">
526 <span className="border-r border-line pr-1.5 text-right">Behind</span>
527 <span className="pl-1.5">Ahead</span>
528 </span>
529 </TableHead>
530 <TableHead className="max-md:hidden">Pull request</TableHead>
531 <TableHead className="w-px">
532 <span className="sr-only">Actions</span>
533 </TableHead>
534 </TableRow>
535 </TableHeader>
536 <TableBody>
537 {rows.map((row) => (
538 <BranchRow key={row.name} row={row} context={context} />
539 ))}
540 </TableBody>
541 </Table>
542 </div>
543 );
544}
545
546/** Making a branch: its name, and the branch, tag or commit it starts at. */
547export function NewBranchDialog({
548 branches,
549 main,
550 onMade,
551}: {
552 /** Every branch's name, the default first. */
553 branches: string[];
554 main: string;
555 onMade: (name: string) => void;
556}) {
557 const [open, setOpen] = useState(false);
558 const [name, setName] = useState("");
559 const [from, setFrom] = useState(main);
560 const [byCommit, setByCommit] = useState(false);
561 const [commit, setCommit] = useState("");
562 const [error, setError] = useState<string | null>(null);
563 const fetcher = useBranchAction((data) => {
564 if (data.error) return setError(data.error);
565 setOpen(false);
566 onMade(data.branch ?? name.trim());
567 });
568 const busy = fetcher.state !== "idle";
569 const source = byCommit ? commit.trim() : from;
570 return (
571 <Dialog
572 open={open}
573 onOpenChange={(now) => {
574 if (now) {
575 setName("");
576 setFrom(main);
577 setByCommit(false);
578 setCommit("");
579 setError(null);
580 }
581 setOpen(now);
582 }}
583 >
584 <Button type="button" size="sm" onClick={() => setOpen(true)}>
585 <GitBranchPlus size={15} />
586 New branch
587 </Button>
588 <DialogContent>
589 <fetcher.Form method="post" className="grid gap-4">
590 <DialogHeader>
591 <DialogTitle>Create a branch</DialogTitle>
592 <DialogDescription>It starts at the same commit as the branch, tag or commit you pick. Push to it, or open a pull request from it.</DialogDescription>
593 </DialogHeader>
594 <input type="hidden" name="intent" value="create" />
595 <input type="hidden" name="from" value={source} />
596 <Field>
597 <FieldLabel htmlFor="new-branch-name">Name</FieldLabel>
598 <Input
599 id="new-branch-name"
600 name="branch"
601 value={name}
602 onChange={(event) => setName(event.target.value)}
603 placeholder="feature/sign-in"
604 maxLength={200}
605 spellCheck={false}
606 autoCapitalize="off"
607 className="font-mono placeholder:font-sans"
608 aria-invalid={error ? true : undefined}
609 autoFocus
610 />
611 {error && <FieldError>{error}</FieldError>}
612 </Field>
613 <Field>
614 <FieldLabel htmlFor="new-branch-from">{byCommit ? "Tag or commit" : "Start from"}</FieldLabel>
615 {byCommit ? (
616 <Input
617 id="new-branch-from"
618 value={commit}
619 onChange={(event) => setCommit(event.target.value)}
620 placeholder="v1.2.0 or 9f2c4e1"
621 spellCheck={false}
622 autoCapitalize="off"
623 className="font-mono placeholder:font-sans"
624 />
625 ) : (
626 <Combobox
627 id="new-branch-from"
628 aria-label="Branch to start from"
629 value={from}
630 onValueChange={setFrom}
631 searchPlaceholder="Find a branch…"
632 emptyText="No branch matches."
633 className="font-mono"
634 options={branches.map((branch) => ({ value: branch, label: branch, icon: <GitBranch /> }))}
635 />
636 )}
637 <FieldDescription>
638 <Button type="button" variant="link" size="inline" className="text-xs" onClick={() => setByCommit(!byCommit)}>
639 {byCommit ? "Start from a branch instead" : "Start from a tag or commit instead"}
640 </Button>
641 </FieldDescription>
642 </Field>
643 <DialogFooter>
644 <Button type="button" variant="outline" onClick={() => setOpen(false)}>
645 Cancel
646 </Button>
647 <Button type="submit" disabled={busy || name.trim() === "" || source === ""}>
648 {busy && <LoaderCircle size={14} className="animate-spin" aria-hidden />}
649 Create branch
650 </Button>
651 </DialogFooter>
652 </fetcher.Form>
653 </DialogContent>
654 </Dialog>
655 );
656}
657
658// --- The default branch's protection hint, dismissed per person and repository ---
659
660const listeners = new Set<() => void>();
661const subscribe = (listener: () => void) => {
662 listeners.add(listener);
663 return () => listeners.delete(listener);
664};
665const storage = () => (typeof window === "undefined" ? null : window.localStorage);
666
667/**
668 * That the default branch has no rules, for people who can add them, until
669 * they dismiss it: remembered by this browser for them and this repository.
670 */
671export function ProtectCallout({ base, main, dismissKey }: { base: string; main: string; dismissKey: string }) {
672 const hidden = useSyncExternalStore(
673 subscribe,
674 useCallback(() => {
675 try {
676 return storage()?.getItem(dismissKey) === "1";
677 } catch {
678 return false;
679 }
680 }, [dismissKey]),
681 () => false,
682 );
683 if (hidden) return null;
684 const dismiss = () => {
685 try {
686 storage()?.setItem(dismissKey, "1");
687 } catch {
688 // Not remembered: it hides until the page is opened again.
689 }
690 for (const listener of listeners) listener();
691 };
692 return (
693 <Alert tone="warn" className="flex flex-wrap items-start gap-3 px-4 py-3">
694 <ShieldAlert size={16} className="mt-0.5 shrink-0 text-warn" />
695 <div className="min-w-0 grow basis-60">
696 <AlertTitle>{main} isn’t protected</AlertTitle>
697 <AlertDescription>
698 Anyone who can push can push straight to it or force-push over it. Add a ruleset to require pull requests and passing checks before anything lands on it.
699 </AlertDescription>
700 </div>
701 <div className="flex shrink-0 items-center gap-2">
702 <Button asChild size="xs" variant="outline">
703 <Link to={`${base}/settings/rules/new`}>Protect this branch</Link>
704 </Button>
705 <Hint label="Dismiss">
706 <Button type="button" variant="ghost" size="icon-xs" aria-label="Dismiss" onClick={dismiss}>
707 <X size={14} />
708 </Button>
709 </Hint>
710 </div>
711 </Alert>
712 );
713}