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.

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