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.

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