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.

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