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