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.

g1t.sh
360 linesCodeBlameRaw
1/**
2 * The top of a project's overview for what g1t.page does not host: an app
3 * that deploys itself, an app nobody has said where it runs, docs, and
4 * anything else. An app on g1t keeps its production card, and a library
5 * or a tool its packages (routes/repo/overview.tsx).
6 */
7import { ArrowUpRight, BookOpen, Box, ChevronDown, GitCommitHorizontal, Globe, Rocket } from "lucide-react";
8import { type ReactNode, useState } from "react";
9import { Link, useFetcher } from "react-router";
10
11import type { DeployStatus, Project } from "@g1t/contracts";
12
13import { DeployLink, host, StatusDot } from "./deploy";
14import { ButtonLink, Input, SubmitButton, TimeAgo } from "./ui";
15import { Button } from "./ui/button";
16import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
17import { Hint } from "./ui/hint";
18
19/**
20 * A deployment g1t.page did not build: one a workflow's job made, or one
21 * reported by another pipeline. When there is one for production, the
22 * card shows it.
23 */
24export type ExternalDeployment = {
25 environment: string;
26 url: string | null;
27 /** success, failure, error, pending, queued, in_progress or inactive. */
28 state: string;
29 sha: string;
30 created_at: string;
31 /** The workflow run that made it, when g1t Actions did. */
32 run_url?: string | null;
33};
34
35/** A reported deployment's state, as g1t's own deployments show theirs. */
36export function deploymentStatus(state: string): DeployStatus {
37 switch (state) {
38 case "success":
39 return "ready";
40 case "failure":
41 case "error":
42 return "failed";
43 case "in_progress":
44 return "building";
45 case "inactive":
46 return "replaced";
47 default:
48 return "queued";
49 }
50}
51
52type Head = {
53 project: Project;
54 base: string;
55 /** May change the project's settings: what it is, its links. */
56 canChange: boolean;
57 /** May turn on Deployments. */
58 canDeploy: boolean;
59};
60
61export function HeadLabel({ icon, children }: { icon: ReactNode; children: ReactNode }) {
62 return (
63 <p className="flex items-center gap-2 text-xs font-medium tracking-wide text-muted uppercase [&_svg]:size-3.25 [&_svg]:text-accent">
64 {icon}
65 {children}
66 </p>
67 );
68}
69
70function BigLink({ url }: { url: string }) {
71 return (
72 <DeployLink href={url} className="mt-2 flex min-w-0 items-center gap-1.5 font-mono text-lg font-medium hover:text-accent">
73 <span className="truncate">{host(url).replace(/\/$/, "")}</span>
74 <ArrowUpRight size={16} className="shrink-0 text-faint" />
75 </DeployLink>
76 );
77}
78
79/** An address, saved for the project in place: production's, or its docs'. */
80function AddressForm({
81 field,
82 placeholder,
83 label,
84 extra,
85}: {
86 field: "productionUrl" | "docsUrl";
87 placeholder: string;
88 label: string;
89 /** Hidden fields sent with it. */
90 extra: Record<string, string>;
91}) {
92 const fetcher = useFetcher<{ error?: string }>();
93 return (
94 <fetcher.Form method="post" className="mt-3 max-w-lg">
95 {Object.entries(extra).map(([name, value]) => (
96 <input key={name} type="hidden" name={name} value={value} />
97 ))}
98 <div className="flex items-center gap-2">
99 <div className="min-w-0 grow">
100 <Input name={field} type="text" inputMode="url" required placeholder={placeholder} aria-label={label} maxLength={255} />
101 </div>
102 <SubmitButton fetcher={fetcher} variant="outline" pending="Saving…">
103 Save
104 </SubmitButton>
105 </div>
106 {fetcher.data?.error && <p className="mt-1.5 text-xs text-danger">{fetcher.data.error}</p>}
107 </fetcher.Form>
108 );
109}
110
111/** Deploying on g1t, offered as an option and never as a step. */
112function DeployQuietly({ base, canDeploy, what }: { base: string; canDeploy: boolean; what: string }) {
113 if (!canDeploy) return null;
114 return (
115 <p className="mt-4 text-xs text-faint">
116 g1t can deploy {what} on g1t.page instead, with a preview for every pull request.{" "}
117 <Link to={`${base}/settings/deployments`} className="underline-offset-4 hover:text-fg hover:underline">
118 Deployment settings
119 </Link>
120 </p>
121 );
122}
123
124/**
125 * An app that deploys itself (its workflows, or another pipeline): production at the address it was
126 * given, with the latest reported deployment, or else the default branch's
127 * latest commit and its checks.
128 */
129export function ElsewhereHead({
130 project,
131 base,
132 canChange,
133 canDeploy,
134 deployment,
135 commit,
136 checks,
137 shot,
138}: Head & {
139 deployment?: ExternalDeployment | null;
140 commit: { hash: string; authoredAt: string } | null;
141 /** The commit's checks, as a badge; left out until there is one to show. */
142 checks?: ReactNode;
143 /** Production's picture, beside the rest, as an app on g1t has (components/production-shot.tsx). */
144 shot?: ReactNode;
145}) {
146 const url = deployment?.url ?? project.productionUrl;
147 return (
148 <div className="flex flex-col gap-5 p-5 sm:flex-row sm:items-start sm:p-6">
149 {shot}
150 <div className="flex min-w-0 grow flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
151 <div className="min-w-0">
152 <HeadLabel icon={<Rocket />}>Production</HeadLabel>
153 {url ? (
154 <>
155 <BigLink url={url} />
156 <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
157 {deployment ? (
158 <>
159 <StatusDot status={deploymentStatus(deployment.state)} />
160 <span className="inline-flex items-center gap-1 font-mono">
161 <GitCommitHorizontal size={13} className="text-faint" />
162 {deployment.sha.slice(0, 7)}
163 </span>
164 <span>
165 deployed <TimeAgo at={deployment.created_at} />
166 {deployment.run_url ? (
167 <>
168 {" "}
169 by{" "}
170 <Link to={deployment.run_url} className="underline-offset-4 hover:text-fg hover:underline">
171 its workflow
172 </Link>
173 </>
174 ) : (
175 " by its own pipeline"
176 )}
177 </span>
178 </>
179 ) : (
180 <>
181 <span>Deploys itself</span>
182 {commit && (
183 <span className="inline-flex items-center gap-1.5">
184 <span className="text-faint">·</span>
185 <Link to={`${base}/commit/${commit.hash}`} className="inline-flex items-center gap-1 font-mono hover:text-fg">
186 <GitCommitHorizontal size={13} className="text-faint" />
187 {commit.hash.slice(0, 7)}
188 </Link>
189 {checks}
190 <TimeAgo at={commit.authoredAt} />
191 </span>
192 )}
193 </>
194 )}
195 </p>
196 </>
197 ) : canChange ? (
198 <>
199 <p className="mt-2 text-lg font-medium">Where is production?</p>
200 <p className="mt-1 max-w-lg text-sm text-muted">
201 {project.name} deploys itself. Give production's address, and this card links to it.
202 </p>
203 <AddressForm field="productionUrl" placeholder="https://example.com" label="Production's address" extra={{ intent: "kind", choice: "elsewhere" }} />
204 </>
205 ) : (
206 <p className="mt-2 text-sm text-muted">Deploys itself.</p>
207 )}
208 <DeployQuietly base={base} canDeploy={canDeploy} what="it" />
209 </div>
210 {url && (
211 <div className="flex shrink-0 items-center gap-2">
212 <ButtonLink to={url} variant="accent" reloadDocument target="_blank" rel="noopener noreferrer">
213 Visit
214 <ArrowUpRight size={14} />
215 </ButtonLink>
216 </div>
217 )}
218 </div>
219 </div>
220 );
221}
222
223const NOT_DEPLOYED: { value: string; label: string }[] = [
224 { value: "library", label: "A library or package" },
225 { value: "tool", label: "A tool or CLI" },
226 { value: "docs", label: "Documentation" },
227 { value: "other", label: "Something else" },
228];
229
230/**
231 * An app that nobody has said where it runs, with Deployments off: one
232 * question, answered in place, instead of a push to turn Deployments on.
233 */
234export function WhereItRuns({ project, base, canChange, canDeploy }: Head) {
235 const fetcher = useFetcher<{ error?: string }>();
236 const [elsewhere, setElsewhere] = useState(false);
237 if (!canChange) {
238 return (
239 <div className="p-5 sm:p-6">
240 <HeadLabel icon={<Globe />}>Homepage</HeadLabel>
241 {project.links.homepage ? <BigLink url={project.links.homepage} /> : <p className="mt-2 text-sm text-muted">No homepage given.</p>}
242 </div>
243 );
244 }
245 return (
246 <div className="p-5 sm:p-6">
247 <HeadLabel icon={<Rocket />}>Production</HeadLabel>
248 <p className="mt-2 text-lg font-medium">Where does {project.name} run?</p>
249 <p className="mt-1 max-w-xl text-sm text-muted">
250 Say once, and this page follows: production where it is deployed, or its packages and releases if it isn't
251 deployed at all. <span className="text-faint">{project.detected.reason.detail}</span>
252 </p>
253 <div className="mt-4 flex flex-wrap items-center gap-2">
254 {canDeploy && (
255 <ButtonLink to={`${base}/settings/deployments`} variant="outline">
256 <Rocket size={14} />
257 Deploy on g1t
258 </ButtonLink>
259 )}
260 <Button
261 type="button"
262 onClick={() => setElsewhere((open) => !open)}
263 aria-expanded={elsewhere}
264 variant="outline"
265 >
266 <Globe size={14} />
267 It deploys itself
268 </Button>
269 <DropdownMenu>
270 <DropdownMenuTrigger asChild>
271 <Button variant="outline">
272 <Box size={14} />
273 It isn't deployed
274 <ChevronDown size={13} className="text-faint" />
275 </Button>
276 </DropdownMenuTrigger>
277 <DropdownMenuContent align="start">
278 {NOT_DEPLOYED.map((option) => (
279 <DropdownMenuItem key={option.value} onSelect={() => fetcher.submit({ intent: "kind", choice: option.value }, { method: "post" })}>
280 {option.label}
281 </DropdownMenuItem>
282 ))}
283 </DropdownMenuContent>
284 </DropdownMenu>
285 </div>
286 {elsewhere && (
287 <AddressForm field="productionUrl" placeholder="Production's address, such as https://example.com" label="Production's address" extra={{ intent: "kind", choice: "elsewhere" }} />
288 )}
289 {fetcher.data?.error && <p className="mt-2 text-xs text-danger">{fetcher.data.error}</p>}
290 </div>
291 );
292}
293
294/** Documentation: where it is read, or a place to say so. */
295export function DocsHead({ project, base, canChange, canDeploy }: Head) {
296 const url = project.links.docs ?? project.productionUrl ?? project.links.homepage;
297 return (
298 <div className="flex flex-col gap-4 p-5 sm:flex-row sm:items-start sm:justify-between sm:p-6">
299 <div className="min-w-0">
300 <HeadLabel icon={<BookOpen />}>Documentation</HeadLabel>
301 {url ? (
302 <BigLink url={url} />
303 ) : canChange ? (
304 <>
305 <p className="mt-2 text-lg font-medium">Where are its docs read?</p>
306 <p className="mt-1 max-w-lg text-sm text-muted">Give the address, and this card and its listing link to it.</p>
307 <AddressForm field="docsUrl" placeholder="https://docs.example.com" label="Where its docs are read" extra={{ intent: "about" }} />
308 </>
309 ) : (
310 <p className="mt-2 text-sm text-muted">Its docs are in its repository.</p>
311 )}
312 <DeployQuietly base={base} canDeploy={canDeploy && project.runs == null} what="its docs as a site" />
313 </div>
314 {url && (
315 <div className="flex shrink-0 items-center gap-2">
316 <ButtonLink to={url} variant="accent" reloadDocument target="_blank" rel="noopener noreferrer">
317 Read
318 <ArrowUpRight size={14} />
319 </ButtonLink>
320 </div>
321 )}
322 </div>
323 );
324}
325
326/** Anything else: its homepage, or where to add its links. */
327export function OtherHead({ project, base, canChange }: Omit<Head, "canDeploy">) {
328 const url = project.links.homepage ?? project.links.docs ?? project.links.custom[0]?.url ?? null;
329 return (
330 <div className="p-5 sm:p-6">
331 <HeadLabel icon={<Globe />}>Homepage</HeadLabel>
332 {url ? (
333 <BigLink url={url} />
334 ) : canChange ? (
335 <p className="mt-2 text-sm text-muted">
336 No links yet.{" "}
337 <Link to={`${base}/settings#links`} className="text-accent hover:underline">
338 Add a homepage, docs or others
339 </Link>{" "}
340 and they show here, on its card and in its workspace's Projects.
341 </p>
342 ) : (
343 <p className="mt-2 text-sm text-muted">No homepage given.</p>
344 )}
345 </div>
346 );
347}
348
349/** Production as g1t serves it, with the project's homepage when that is somewhere else. */
350export function AlsoAt({ url }: { url: string | null }) {
351 if (!url) return null;
352 return (
353 <Hint label="Its homepage">
354 <DeployLink href={url} className="inline-flex items-center gap-1 text-faint hover:text-fg">
355 <Globe size={12} />
356 {host(url).replace(/\/$/, "")}
357 </DeployLink>
358 </Hint>
359 );
360}