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 | */ | |
| 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 ( | |
| 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 | ||
| 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 | } |