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.

294 linesCodeBlameRaw
1import { Box, Code2, GitBranch, Image as ImageIcon, Link2, Rocket } from "lucide-react";
2import { useState } from "react";
3import { Form, Link, redirect } from "react-router";
4
5import { DEFAULT_MEMBER_PRIVILEGES } from "@g1t/contracts";
6
7import type { Route } from "./+types/settings";
8import { LIFECYCLE_INTENTS, type LifecycleIntent, ProjectDangerZone } from "../../components/project-lifecycle";
9import { confirmsName } from "../../lib/repo-lifecycle";
10import { page } from "../../lib/meta";
11import { useAddresses } from "../../lib/addresses";
12import { productionImageUrl, readProductionImageUpload } from "../../lib/production-image";
13import { KindFields, LinkFields } from "../../components/project-about";
14import { ProductionImageField } from "../../components/production-image-field";
15import { RepoSettingsHeading } from "../../components/repo-settings-heading";
16import { ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui";
17import { Card } from "../../components/ui/card";
18import { KIND_CHOICE_SETS, type KindChoice, choiceOf, linksFromForm, neverDeploys } from "../../lib/project-kind";
19import { deployments, identity, projects, repos } from "../../lib/services.server";
20import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
21import { refusal, requireCapability, requireInsider } from "../../lib/access.server";
22
23export function meta({ params, ...args }: Route.MetaArgs) {
24 return page(args, { title: `Settings · ${params.owner}/${params.repo} · g1t` });
25}
26
27export async function loader({ params, context }: Route.LoaderArgs) {
28 const viewer = getViewer(context);
29 // Maintain and up; to anyone without a role here the page does not exist.
30 const { repo, access } = await requireInsider(context, params, "manage_settings");
31 const [project, deploys] = await Promise.all([
32 projects.get(params.owner, params.repo, viewer),
33 // Whether Deployments are on: needs Admin, so unknown (null) below it.
34 deployments.settings({ workspace: params.owner, slug: params.repo }, viewer).catch(() => null),
35 ]);
36 const found = unwrap(project);
37 // Who made it is kept as an id: named by identity, or left out if it cannot say.
38 const names = await identity.usernames([found.createdBy]).catch(() => ({}) as Record<string, string>);
39 return {
40 project: found,
41 deploymentsOn: deploys?.ok ? deploys.value.enabled : null,
42 mine: viewer?.id === found.createdBy,
43 creator: names[found.createdBy] ?? null,
44 // Archiving is for Admins; transferring and deleting, for owners of the
45 // workspace unless its member privileges let its members with Admin.
46 lifecycle: access.can.administer
47 ? { archived: Boolean(repo.archivedAt), remove: access.can.delete, destinations: destinations(viewer, params.owner, repo.isPrivate, access.can.delete) }
48 : null,
49 };
50}
51
52/** Where it can be transferred: a workspace where the person can create a repository like it. */
53function destinations(
54 viewer: ReturnType<typeof getViewer>,
55 from: string,
56 isPrivate: boolean,
57 allowed: boolean,
58): { slug: string; name: string }[] {
59 if (!allowed) return [];
60 return (viewer?.workspaces ?? [])
61 .filter((m) => m.slug !== from.toLowerCase())
62 .filter((m) => {
63 if (m.role === "owner") return true;
64 const privileges = { ...DEFAULT_MEMBER_PRIVILEGES, ...(m.privileges ?? {}) };
65 return isPrivate ? privileges.members_can_create_private_repositories : privileges.members_can_create_public_repositories;
66 })
67 .map((m) => ({ slug: m.slug, name: m.name ?? m.slug }));
68}
69
70/** Archiving, transferring and deleting: the repository goes with the project. */
71async function lifecycle(intent: LifecycleIntent, form: FormData, request: Request, params: Route.ActionArgs["params"], context: Route.ActionArgs["context"]) {
72 const user = requireUser(context, request);
73 const refused = await refusal(context, params, intent === "archive" ? "administer" : "delete");
74 if (refused) return { lifecycle: intent, error: refused };
75 const path = { namespace: params.owner, name: params.repo };
76 const full = `${params.owner}/${params.repo}`;
77 const typed = String(form.get("confirm") ?? "").trim();
78 const failed = (result: { ok: false; error: { message: string } }) => ({ lifecycle: intent, error: result.error.message });
79 switch (intent) {
80 case "archive": {
81 const done = await repos.archive(user, path, form.get("archived") === "true");
82 return done.ok ? { lifecycle: intent, error: null } : failed(done);
83 }
84 case "transfer": {
85 // repos checks both workspaces' owners, the name and storage, and
86 // keeps the old address as a redirect.
87 if (!confirmsName(typed, full)) return { lifecycle: intent, error: `Type ${full} to confirm.` };
88 const moved = await repos.transfer(user, path, String(form.get("to") ?? "").trim().toLowerCase());
89 if (!moved.ok) return failed(moved);
90 throw redirect(`/${moved.value.namespace}/${moved.value.name}/settings`);
91 }
92 case "delete": {
93 if (!confirmsName(typed, full)) return { lifecycle: intent, error: `Type ${full} to confirm.` };
94 const deleted = await repos.delete(user, path, typed);
95 if (!deleted.ok) return failed(deleted);
96 throw redirect(`/${params.owner}/-/repositories?deleted=${encodeURIComponent(deleted.value.name)}`);
97 }
98 }
99}
100
101export async function action({ request, params, context }: Route.ActionArgs) {
102 assertSameOrigin(request);
103 const user = requireUser(context, request);
104 const form = await request.formData();
105 const intent = form.get("intent");
106 if (LIFECYCLE_INTENTS.includes(intent as LifecycleIntent)) {
107 return lifecycle(intent as LifecycleIntent, form, request, params, context);
108 }
109 await requireCapability(context, params, "manage_settings");
110 // Production's picture on the overview: whoever may change its address may change it.
111 if (intent === "production-image" || intent === "remove-production-image" || intent === "prefer-production-image") {
112 let change: { image?: string | null; preferred?: boolean };
113 if (intent === "production-image") {
114 const upload = await readProductionImageUpload(form);
115 if ("error" in upload) return { error: upload.error };
116 change = { image: upload.image };
117 } else {
118 change = intent === "remove-production-image" ? { image: null } : { preferred: form.get("preferred") === "on" };
119 }
120 const saved = await projects.setProductionImage(user, params.owner, params.repo, change);
121 return saved.ok ? { imageSaved: true as const } : { error: saved.error.message };
122 }
123 const choice = form.get("choice") as KindChoice | null;
124 const set = choice && choice in KIND_CHOICE_SETS ? KIND_CHOICE_SETS[choice] : null;
125 const saved = await projects.update(user, params.owner, params.repo, {
126 name: String(form.get("name") ?? ""),
127 // Blank clears it.
128 description: String(form.get("description") ?? ""),
129 rootDir: String(form.get("rootDir") ?? ""),
130 ...(set ?? {}),
131 // Production's address is asked for beside Deploys itself, and kept otherwise.
132 ...(form.has("productionUrl") ? { productionUrl: String(form.get("productionUrl")) } : {}),
133 homepage: String(form.get("homepage") ?? ""),
134 docsUrl: String(form.get("docsUrl") ?? ""),
135 links: linksFromForm(form),
136 });
137 return saved.ok ? { saved: true as const } : { error: saved.error.message };
138}
139
140export default function ProjectSettings({ loaderData, actionData, params }: Route.ComponentProps) {
141 const { project, deploymentsOn, mine, creator, lifecycle } = loaderData;
142 const [choice, setChoice] = useState<KindChoice | null>(choiceOf(project.setting));
143 // Something that never deploys, while Deployments are on, is refused: they are turned off first.
144 const stops = choice === "library" || choice === "tool" || choice === "other";
145 const blocked = stops && !neverDeploys(project) && deploymentsOn === true;
146 const base = `/${params.owner}/${params.repo}`;
147 const source = project.source.kind === "hosted" ? project.source : null;
148 const { usercontent } = useAddresses();
149 return (
150 <div className="max-w-4xl">
151 <RepoSettingsHeading base={base} />
152 <Form method="post" className="space-y-8">
153 {/* Enter in a field saves: the form's first submit button is the one
154 Enter presses, and otherwise it would be the description reset. */}
155 <button type="submit" name="intent" value="save" tabIndex={-1} aria-hidden="true" className="absolute size-0 overflow-hidden opacity-0" />
156 <section className="space-y-4">
157 <h2 className="flex items-center gap-2 text-sm font-medium">
158 <Box size={15} className="text-accent" />
159 Project
160 </h2>
161 <div className="grid gap-4 md:grid-cols-2">
162 <Field label="Name" hint={`Its address stays g1t.sh/${project.workspace}/${project.slug}.`}>
163 <Input name="name" defaultValue={project.name} required maxLength={100} />
164 </Field>
165 <Field label="Description" hint="What it is, in a line. Search, link previews and lists of repositories show it.">
166 <Input
167 key={project.description ?? ""}
168 name="description"
169 defaultValue={project.description ?? ""}
170 maxLength={200}
171 placeholder="What it is, in a line"
172 />
173 </Field>
174 </div>
175 </section>
176
177 <section className="space-y-4">
178 <h2 className="flex items-center gap-2 text-sm font-medium">
179 <Code2 size={15} className="text-accent" />
180 Source
181 </h2>
182 {source && (
183 <Card className="p-4 text-sm">
184 <div className="flex flex-wrap items-center justify-between gap-3">
185 <div>
186 <Link to={`${base}/code`} className="font-mono hover:text-accent">
187 {source.repo.namespace}/{source.repo.name}
188 </Link>
189 <p className="mt-0.5 text-xs text-muted">
190 Hosted on g1t · <GitBranch size={11} className="inline" /> {source.defaultBranch}
191 </p>
192 </div>
193 <Link to={`${base}/settings/repository`} className="text-xs text-muted hover:text-fg">
194 Repository settings
195 </Link>
196 </div>
197 </Card>
198 )}
199 <Field
200 label="Root directory"
201 hint="Where in the repository this project lives, such as apps/web. Builds run there. Empty for the whole repository."
202 >
203 <Input name="rootDir" defaultValue={source?.rootDir ?? ""} placeholder="apps/web" className="font-mono" />
204 </Field>
205 </section>
206
207 <section className="scroll-mt-20 space-y-4" id="kind">
208 {/* Old links to #deploys land here too. */}
209 <span id="deploys" />
210 <h2 className="flex items-center gap-2 text-sm font-medium">
211 <Rocket size={15} className="text-accent" />
212 What it is
213 </h2>
214 <p className="max-w-2xl text-xs text-muted">
215 Its overview follows: production for what is deployed, packages and releases for a library or a tool, and
216 the steps that apply to it. Only an app or site hosted on g1t.page is asked to turn on Deployments.
217 </p>
218 <KindFields project={project} choice={choice} onChoice={setChoice} />
219 {blocked && (
220 <p className="rounded-lg border border-warn/40 bg-warn/5 px-3.5 py-2.5 text-sm text-fg-soft">
221 Deployments are on for {project.name}. Turn them off in{" "}
222 <Link to={`${base}/settings/deployments`} className="text-fg underline underline-offset-4">
223 Deployments settings
224 </Link>{" "}
225 first, then choose this here.
226 </p>
227 )}
228 </section>
229
230 <section className="scroll-mt-20 space-y-4" id="links">
231 <h2 className="flex items-center gap-2 text-sm font-medium">
232 <Link2 size={15} className="text-accent" />
233 Links
234 </h2>
235 <p className="max-w-2xl text-xs text-muted">
236 Shown on its overview, its card, and its workspace's Projects. Each is an http or https address;
237 https:// is added when you leave it out.
238 </p>
239 <LinkFields links={project.links} />
240 </section>
241
242 <div className="flex items-center gap-3">
243 <SubmitButton name="intent" value="save" pending="Saving…" disabled={blocked}>
244 Save
245 </SubmitButton>
246 {actionData && "saved" in actionData && <span className="text-sm text-success">Saved.</span>}
247 <span className="ml-auto text-xs text-faint">
248 Created{" "}
249 {mine ? (
250 "by you "
251 ) : creator ? (
252 <>
253 by{" "}
254 <Link to={`/${creator}`} className="text-muted hover:text-fg">
255 {creator}
256 </Link>{" "}
257 </>
258 ) : null}
259 <TimeAgo at={project.createdAt} />
260 </span>
261 </div>
262 <ErrorText>{actionData && "error" in actionData && !("lifecycle" in actionData) ? actionData.error : null}</ErrorText>
263 </Form>
264
265 {/* Its own forms, sent as soon as an image is picked: outside the settings form. */}
266 <section className="mt-10 scroll-mt-20 space-y-4" id="production-image">
267 <h2 className="flex items-center gap-2 text-sm font-medium">
268 <ImageIcon size={15} className="text-accent" />
269 Production image
270 </h2>
271 <p className="max-w-2xl text-xs text-muted">
272 The picture on its overview's production card. A screenshot is taken by itself for an app hosted on g1t.page,
273 and of production's address for one that deploys itself, about once a day and after each production deployment
274 reported to g1t.
275 </p>
276 <ProductionImageField
277 image={project.productionImage}
278 src={project.productionImage ? productionImageUrl(project.productionImage.file, usercontent) : null}
279 screenshots={project.runs === "g1t" || (project.runs === "elsewhere" && !!project.productionUrl)}
280 />
281 </section>
282
283 {lifecycle && (
284 <ProjectDangerZone
285 full={`${params.owner}/${params.repo}`}
286 archived={lifecycle.archived}
287 remove={lifecycle.remove}
288 destinations={lifecycle.destinations}
289 error={(intent) => (actionData && "lifecycle" in actionData && actionData.lifecycle === intent ? actionData.error : null)}
290 />
291 )}
292 </div>
293 );
294}