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.

g1t.sh
712 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

The Branches page does things to branches now. It has tabs (Overview, Yours, Active, Stale, All, kept in the address as ?tab=) and search; Overview shows the default branch, then the first five of your branches, the active ones and the stale ones, each linking to the rest. Each branch is a row with its name in a code chip with a copy button, who made its latest commit and when, that commit's checks, behind and ahead counts with bars drawn against the largest shown, and its pull request (the open one, else the newest merged or closed) with its state; on a phone the columns fold under the name. Yours is branches whose latest commit is the viewer's; stale is no commit in 90 days.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 (
The Branches page hydrates cleanly. Each row's Updated column had the commit's avatars, which link to their people, inside the link to the commit; the browser splits a link inside a link apart, so the page's HTML never matched what React drew and it threw it away and drew again (React error 418). The avatars now sit beside the commit's link, and the time alone links to the commit.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>
The Branches page does things to branches now. It has tabs (Overview, Yours, Active, Stale, All, kept in the address as ?tab=) and search; Overview shows the default branch, then the first five of your branches, the active ones and the stale ones, each linking to the rest. Each branch is a row with its name in a code chip with a copy button, who made its latest commit and when, that commit's checks, behind and ahead counts with bars drawn against the largest shown, and its pull request (the open one, else the newest merged or closed) with its state; on a phone the columns fold under the name. Yours is branches whose latest commit is the viewer's; stale is no commit in 90 days.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}