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