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.

144 linesCodeBlameRaw
1import { Archive } from "lucide-react";
2import { type ReactNode, useEffect, useId, useRef, useState } from "react";
3import { Form, Link } from "react-router";
4
5import { ErrorText, SubmitButton, usePending } from "./ui";
6import {
7 AlertDialog,
8 AlertDialogCancel,
9 AlertDialogContent,
10 AlertDialogDescription,
11 AlertDialogFooter,
12 AlertDialogHeader,
13 AlertDialogTitle,
14} from "./ui/alert-dialog";
15import { ConfirmName } from "./confirm-name";
16import { confirmsName } from "../lib/repo-lifecycle";
17
18/**
19 * A dialog that posts one `intent`, says what will happen as a list, and,
20 * when `confirm` is given, asks for that name to be typed first. It opens
21 * from `trigger`, and opens by itself again when the action sent back an
22 * error, so the error is seen beside what caused it.
23 */
24export function ConfirmDialog({
25 intent,
26 fields,
27 title,
28 description,
29 children,
30 confirm,
31 submit,
32 busy,
33 error,
34 danger = true,
35 action,
36 trigger,
37 extra,
38}: {
39 intent: string;
40 /** Other hidden fields to post with it. */
41 fields?: Record<string, string>;
42 title: ReactNode;
43 description?: ReactNode;
44 /** What happens, as list items. */
45 children?: ReactNode;
46 /** The name to type, such as `acme/web`; nothing to type when absent. */
47 confirm?: string;
48 submit: string;
49 /** The button's words while it is posting. */
50 busy: string;
51 error?: string | null;
52 danger?: boolean;
53 /** Where to post, when not the page's own action. */
54 action?: string;
55 trigger: (open: () => void) => ReactNode;
56 /** More fields to post, under the list: a choice that goes with it. */
57 extra?: ReactNode;
58}) {
59 const [open, setOpen] = useState(Boolean(error));
60 const [typed, setTyped] = useState("");
61 const id = useId();
62 const posting = usePending({ intent, ...fields });
63 // Done without an error, it closes: the page behind it now shows the change.
64 const wasPosting = useRef(false);
65 useEffect(() => {
66 if (wasPosting.current && !posting && !error) {
67 setOpen(false);
68 setTyped("");
69 }
70 wasPosting.current = posting;
71 }, [posting, error]);
72 const ready = confirm == null || confirmsName(typed, confirm);
73 return (
74 <>
75 {trigger(() => setOpen(true))}
76 <AlertDialog
77 open={open}
78 onOpenChange={(next) => {
79 setOpen(next);
80 setTyped("");
81 }}
82 >
83 <AlertDialogContent>
84 <Form method="post" action={action} className="grid gap-4">
85 <input type="hidden" name="intent" value={intent} />
86 {Object.entries(fields ?? {}).map(([name, value]) => (
87 <input key={name} type="hidden" name={name} value={value} />
88 ))}
89 <AlertDialogHeader>
90 <AlertDialogTitle>{title}</AlertDialogTitle>
91 {description && <AlertDialogDescription>{description}</AlertDialogDescription>}
92 </AlertDialogHeader>
93 {children && <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted">{children}</ul>}
94 {extra}
95 {confirm != null && (
96 <ConfirmName confirm={confirm} id={`${id}-confirm`} value={typed} onChange={(event) => setTyped(event.target.value)} />
97 )}
98 <ErrorText>{error}</ErrorText>
99 <AlertDialogFooter>
100 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
101 <SubmitButton variant={danger ? "destructive" : "default"} disabled={!ready} match={{ intent, ...fields }} pending={busy}>
102 {submit}
103 </SubmitButton>
104 </AlertDialogFooter>
105 </Form>
106 </AlertDialogContent>
107 </AlertDialog>
108 </>
109 );
110}
111
112/**
113 * Said across the top of an archived repository's pages: what archived
114 * means, and for owners, where to undo it. On its settings pages it says
115 * why the settings are turned off.
116 */
117export function ArchivedBanner({ base, owner, settings }: { base: string; owner: boolean; settings?: boolean }) {
118 return (
119 <div
120 role="status"
121 className="flex flex-col gap-2 rounded-lg border border-warn/40 bg-warn/5 px-4 py-3 text-sm sm:flex-row sm:items-start sm:justify-between"
122 >
123 <p className="flex gap-2.5">
124 <Archive size={16} className="mt-0.5 shrink-0 text-warn" />
125 <span>
126 <span className="font-medium text-fg">This repository is archived.</span>{" "}
127 <span className="text-muted">
128 {settings
129 ? "It is read-only, so settings that change it are turned off until it is unarchived."
130 : "It is read-only: pushes and merges are refused, issues and pull requests are locked, and workflows do not run. Its deployments keep serving."}
131 </span>
132 </span>
133 </p>
134 {owner && (
135 <Link
136 to={`${base}/settings#danger-zone`}
137 className="shrink-0 pl-6.5 font-medium text-warn hover:underline sm:pl-0"
138 >
139 Unarchive
140 </Link>
141 )}
142 </div>
143 );
144}