| 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 | */ |
| 8 | import { |
| 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"; |
| 25 | import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; |
| 26 | import { Link, useFetcher } from "react-router"; |
| 27 | |
| 28 | import type { ActiveBranch } from "./branches"; |
| 29 | import type { ChecksSource } from "./commit-checks"; |
| 30 | import { CommitChecksBadge } from "./commit-checks"; |
| 31 | import { CommitAvatars } from "./commit-person"; |
| 32 | import { host } from "./deploy"; |
| 33 | import { CopyLine, TimeAgo } from "./ui"; |
| 34 | import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; |
| 35 | import { |
| 36 | AlertDialog, |
| 37 | AlertDialogAction, |
| 38 | AlertDialogCancel, |
| 39 | AlertDialogContent, |
| 40 | AlertDialogDescription, |
| 41 | AlertDialogFooter, |
| 42 | AlertDialogHeader, |
| 43 | AlertDialogTitle, |
| 44 | } from "./ui/alert-dialog"; |
| 45 | import { Badge } from "./ui/badge"; |
| 46 | import { Button } from "./ui/button"; |
| 47 | import { Combobox } from "./ui/combobox"; |
| 48 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"; |
| 49 | import { |
| 50 | DropdownMenu, |
| 51 | DropdownMenuContent, |
| 52 | DropdownMenuItem, |
| 53 | DropdownMenuSeparator, |
| 54 | DropdownMenuTrigger, |
| 55 | } from "./ui/dropdown-menu"; |
| 56 | import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field"; |
| 57 | import { Hint } from "./ui/hint"; |
| 58 | import { Input } from "./ui/input"; |
| 59 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table"; |
| 60 | import { PullIcon } from "./work-icons"; |
| 61 | import { isMerged } from "../lib/branches"; |
| 62 | |
| 63 | /** What the page's action answers. */ |
| 64 | export 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. */ |
| 67 | export type BranchRowData = ActiveBranch & { deleted?: boolean }; |
| 68 | |
| 69 | /** What every row needs to know about the page. */ |
| 70 | export 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. */ |
| 88 | export 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. */ |
| 93 | function 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. */ |
| 117 | function 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. */ |
| 144 | function 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 | |
| 161 | function 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. */ |
| 166 | function 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 | |
| 190 | const PULL_STATE = { open: "Open", draft: "Draft", merged: "Merged", closed: "Closed" } as const; |
| 191 | |
| 192 | /** The branch's pull request: its state and number, linked. */ |
| 193 | function 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 | */ |
| 216 | function 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. */ |
| 230 | function 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. */ |
| 318 | function 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. */ |
| 369 | function 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. */ |
| 422 | function 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. */ |
| 443 | function 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. */ |
| 457 | function 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. */ |
| 514 | export 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. */ |
| 546 | export 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 | |
| 659 | const listeners = new Set<() => void>(); |
| 660 | const subscribe = (listener: () => void) => { |
| 661 | listeners.add(listener); |
| 662 | return () => listeners.delete(listener); |
| 663 | }; |
| 664 | const 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 | */ |
| 670 | export 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 | } |