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.

227 linesCodeBlameRaw
1import { useState } from "react";
2import { Form, useNavigation } from "react-router";
3
4import { RESTORE_DAYS } from "@g1t/contracts";
5
6import { ConfirmName } from "./confirm-name";
7import { DangerAction, DangerZone } from "./danger-zone";
8import { ConfirmDialog } from "./repo-lifecycle";
9import { ErrorText } from "./ui";
10import {
11 AlertDialog,
12 AlertDialogCancel,
13 AlertDialogContent,
14 AlertDialogDescription,
15 AlertDialogFooter,
16 AlertDialogHeader,
17 AlertDialogTitle,
18} from "./ui/alert-dialog";
19import { Button } from "./ui/button";
20import { FieldLabel, Field as FormField } from "./ui/field";
21import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
22import { confirmsName } from "../lib/repo-lifecycle";
23
24/** The intents General's action takes from these forms. */
25export const LIFECYCLE_INTENTS = ["archive", "transfer", "delete"] as const;
26export type LifecycleIntent = (typeof LIFECYCLE_INTENTS)[number];
27
28/**
29 * The end of General: archiving, moving or deleting the project, its
30 * repository with it. `remove` says whether this person may transfer and
31 * delete (owners, unless member privileges let Admins).
32 */
33export function ProjectDangerZone({
34 full,
35 archived,
36 remove,
37 destinations,
38 error,
39}: {
40 full: string;
41 archived: boolean;
42 remove: boolean;
43 destinations: { slug: string; name: string }[];
44 error: (intent: LifecycleIntent) => string | null;
45}) {
46 return (
47 <div id="danger-zone" className="mt-10 scroll-mt-20 border-t border-line pt-8">
48 <DangerZone>
49 <ArchiveAction full={full} archived={archived} error={error("archive")} />
50 {remove && <TransferAction repo={full} destinations={destinations} error={error("transfer")} />}
51 {remove && <DeleteAction full={full} error={error("delete")} />}
52 </DangerZone>
53 {!remove && (
54 <p className="mt-4 text-sm text-muted">Only an owner of the workspace can transfer or delete it: its member privileges keep that to owners.</p>
55 )}
56 </div>
57 );
58}
59
60/** Read-only, or back to normal. */
61function ArchiveAction({ full, archived, error }: { full: string; archived: boolean; error: string | null }) {
62 return (
63 <ConfirmDialog
64 intent="archive"
65 fields={{ archived: archived ? "false" : "true" }}
66 title={archived ? `Unarchive ${full}?` : `Archive ${full}?`}
67 description={archived ? "It goes back to working as before." : "It becomes read-only. You can unarchive it at any time."}
68 submit={archived ? "Unarchive" : "Archive"}
69 busy={archived ? "Unarchiving…" : "Archiving…"}
70 danger={!archived}
71 error={error}
72 trigger={(open) => (
73 <DangerAction
74 title={archived ? "Unarchive this project" : "Archive this project"}
75 action={
76 <Button type="button" variant="destructive" onClick={open}>
77 {archived ? "Unarchive" : "Archive"}
78 </Button>
79 }
80 >
81 {archived
82 ? "It is archived and its repository read-only. Unarchiving lets pushes, merges and workflows work again."
83 : "Make its repository read-only: everything stays where it is and can be read, and nothing changes it."}
84 </DangerAction>
85 )}
86 >
87 {archived ? (
88 <>
89 <li>Pushes and merges are accepted again.</li>
90 <li>Work items and pull requests unlock.</li>
91 <li>Workflows run again.</li>
92 </>
93 ) : (
94 <>
95 <li>Pushes and merges are refused, for everyone.</li>
96 <li>Work items and pull requests are locked. They stay readable.</li>
97 <li>Workflows do not run.</li>
98 <li>Its deployments keep serving, and anyone who could see it still can.</li>
99 </>
100 )}
101 </ConfirmDialog>
102 );
103}
104
105/** Deleting: hidden at once, restorable for a while, then gone. */
106function DeleteAction({ full, error }: { full: string; error: string | null }) {
107 const namespace = full.split("/")[0];
108 return (
109 <ConfirmDialog
110 intent="delete"
111 title={`Delete ${full}?`}
112 description={`You can restore it for ${RESTORE_DAYS} days.`}
113 confirm={full}
114 submit="Delete project"
115 busy="Deleting…"
116 error={error}
117 trigger={(open) => (
118 <DangerAction
119 title="Delete this project"
120 action={
121 <Button type="button" variant="destructive" onClick={open}>
122 Delete
123 </Button>
124 }
125 >
126 Its repository goes with it. It can be restored from the workspace's repositories for {RESTORE_DAYS} days, then it is removed for good.
127 </DangerAction>
128 )}
129 >
130 <li>It disappears at once with its repository: its pages, git remote and API stop answering, for everyone, and its apps on g1t.page stop serving.</li>
131 <li>
132 Owners can restore it, as it was, from <span className="font-mono text-fg">{namespace}</span>'s Recently deleted repositories for {RESTORE_DAYS} days,
133 custom domains and settings included. After that it is removed for good, its git data with it.
134 </li>
135 <li>Its name stays taken until then, so nothing else can be made at its address.</li>
136 </ConfirmDialog>
137 );
138}
139
140/**
141 * Moving the project, with its repository, to another workspace the person
142 * may create one in. The dialog says what moves and what changes, and asks
143 * for the full name.
144 */
145function TransferAction({ repo, destinations, error }: { repo: string; destinations: { slug: string; name: string }[]; error: string | null }) {
146 const [open, setOpen] = useState(Boolean(error));
147 const [to, setTo] = useState(destinations[0]?.slug ?? "");
148 const [confirm, setConfirm] = useState("");
149 const navigation = useNavigation();
150 const moving = navigation.state !== "idle" && navigation.formData?.get("intent") === "transfer";
151 const name = repo.split("/")[1];
152 return (
153 <DangerAction
154 title="Transfer this project"
155 action={
156 <Button type="button" variant="destructive" disabled={destinations.length === 0} onClick={() => setOpen(true)}>
157 Transfer
158 </Button>
159 }
160 >
161 {destinations.length === 0 ? (
162 <>Move it to another workspace you own. You own no other workspace yet.</>
163 ) : (
164 <>Move it, with its repository, to another workspace you own. Its old address keeps redirecting.</>
165 )}
166 <AlertDialog
167 open={open}
168 onOpenChange={(next) => {
169 setOpen(next);
170 setConfirm("");
171 }}
172 >
173 <AlertDialogContent>
174 <Form method="post" className="grid gap-4">
175 <input type="hidden" name="intent" value="transfer" />
176 <AlertDialogHeader>
177 <AlertDialogTitle>Transfer {repo}</AlertDialogTitle>
178 <AlertDialogDescription>
179 It keeps its name and moves with everything in it: code, work items, pull requests, workflow runs, deployments, and its own secrets, variables
180 and webhooks.
181 </AlertDialogDescription>
182 </AlertDialogHeader>
183 <FormField>
184 <FieldLabel htmlFor="transfer-to">New workspace</FieldLabel>
185 <Select name="to" value={to} onValueChange={setTo}>
186 <SelectTrigger id="transfer-to" aria-label="New workspace">
187 <SelectValue />
188 </SelectTrigger>
189 <SelectContent>
190 {destinations.map((d) => (
191 <SelectItem key={d.slug} value={d.slug}>
192 {d.name === d.slug ? d.slug : `${d.name} (${d.slug})`}
193 </SelectItem>
194 ))}
195 </SelectContent>
196 </Select>
197 </FormField>
198 <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted">
199 <li>
200 It moves to{" "}
201 <span className="font-mono break-all text-fg">
202 g1t.sh/{to}/{name}
203 </span>
204 . Links, git remotes and API calls to <span className="font-mono break-all">g1t.sh/{repo}</span> redirect there until a repository is made at
205 the old address.
206 </li>
207 <li>
208 Apps it deploys move to <span className="font-mono">*.g1t.page</span> names with the new workspace; the old ones redirect for 90 days. Custom
209 domains follow.
210 </li>
211 <li>The old workspace's secrets, webhooks and integrations stop reaching it; the new one's start.</li>
212 <li>Usage from now on is charged to {to}. What it used before stays on the old workspace's bill.</li>
213 </ul>
214 <ConfirmName confirm={repo} id="transfer-confirm" value={confirm} onChange={(event) => setConfirm(event.target.value)} />
215 <ErrorText>{error}</ErrorText>
216 <AlertDialogFooter>
217 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
218 <Button type="submit" variant="destructive" disabled={!to || !confirmsName(confirm, repo) || moving}>
219 {moving ? "Transferring…" : "Transfer"}
220 </Button>
221 </AlertDialogFooter>
222 </Form>
223 </AlertDialogContent>
224 </AlertDialog>
225 </DangerAction>
226 );
227}