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.

180 linesCodeBlameRaw
1import { Box, Download, Globe, Lock, Sparkles } from "lucide-react";
2import { useState } from "react";
3import { Form, redirect, useNavigate } from "react-router";
4
5import type { Route } from "./+types/new";
6import { page } from "../lib/meta";
7import { ErrorText, SubmitButton } from "../components/ui";
8import { Field, FieldDescription, FieldLabel, FieldLegend, FieldSet } from "../components/ui/field";
9import { Input, InputAddon, InputGroup } from "../components/ui/input";
10import { RadioCard, RadioGroup } from "../components/ui/radio-group";
11import { projects, repos } from "../lib/services.server";
12import { GithubMark } from "../components/github";
13import { githubApp } from "../lib/github.server";
14import { assertSameOrigin, requireUser } from "../lib/session.server";
15import { currentWorkspace } from "../lib/current-workspace.server";
16import { Unavailable } from "../components/boundary";
17import { isFailed, part } from "../lib/partial";
18
19export function meta(args: Route.MetaArgs) {
20 return page(args, { title: "New project · g1t" });
21}
22
23export async function loader({ request, context }: Route.LoaderArgs) {
24 const user = requireUser(context, request);
25 // Projects live in a workspace, so there has to be one first; a new one
26 // goes in the workspace you're in.
27 const current = currentWorkspace(user, request);
28 if (!current) throw redirect("/workspaces/new");
29 const selected = current.slug;
30 // With g1t's GitHub App configured, repositories can come from GitHub.
31 // Not asked is not "not set up": the other sources stay, and the picker
32 // says that importing from GitHub could not be checked.
33 const github = await part(githubApp.status(user, selected), "Importing from GitHub");
34 return {
35 selected,
36 name: current.name?.trim() || current.slug,
37 github: !isFailed(github) && github.ok && github.value.configured,
38 githubUnknown: isFailed(github),
39 };
40}
41
42export async function action({ request, context }: Route.ActionArgs) {
43 assertSameOrigin(request);
44 const user = requireUser(context, request);
45 const form = await request.formData();
46 // A project hosted on g1t: its repository is made with it, and the
47 // project takes the repository's name.
48 const result = await repos.create(user, {
49 namespace: String(form.get("workspace") ?? ""),
50 name: String(form.get("name") ?? ""),
51 isPrivate: form.get("visibility") === "private",
52 importUrl: form.get("source") === "import" ? String(form.get("importUrl") ?? "").trim() || undefined : undefined,
53 });
54 if (!result.ok) return { error: result.error.message };
55 // A repository has no description: the one given is its project's.
56 const description = String(form.get("description") ?? "").trim();
57 if (description) {
58 const described = await projects.describe(user, result.value.id, description).catch((error: unknown) => String(error));
59 if (typeof described === "string" || !described.ok) console.error("new repository without its description", result.value.id, described);
60 }
61 throw redirect(`/${result.value.namespace}/${result.value.name}`);
62}
63
64type Source = "empty" | "import";
65
66const SOURCES: { id: Source | "github"; title: string; text: string; icon: React.ReactNode }[] = [
67 {
68 id: "empty",
69 title: "Start empty",
70 text: "A new repository on g1t. Push to it, or hand your coding agent the first work item.",
71 icon: <Sparkles />,
72 },
73 {
74 id: "import",
75 title: "Import code",
76 text: "Copy a public repository from GitHub or any git host into a new one on g1t.",
77 icon: <Download />,
78 },
79];
80
81/** Shown when g1t's GitHub App is configured. */
82const GITHUB_SOURCE = {
83 id: "github" as const,
84 title: "Import from GitHub",
85 text: "Import repositories you can reach on GitHub, private ones too, with their work items.",
86 icon: <GithubMark />,
87};
88
89export default function NewProject({ loaderData, actionData }: Route.ComponentProps) {
90 const [source, setSource] = useState<Source>("empty");
91 const navigate = useNavigate();
92 const sources = loaderData.github ? [...SOURCES, GITHUB_SOURCE] : SOURCES;
93 const workspace = loaderData.selected;
94 const [name, setName] = useState("");
95 return (
96 <main className="mx-auto max-w-2xl px-4 py-12">
97 <span className="flex size-10 items-center justify-center rounded-xl bg-accent/10 text-accent ring-1 ring-accent/30">
98 <Box size={18} />
99 </span>
100 <h1 className="mt-4 text-2xl font-semibold tracking-tight">New project</h1>
101 <p className="mt-1.5 text-sm text-muted">
102 A project is what you build and run. Its code lives in its source; its deployments, environments, secrets and variables belong to the project.
103 </p>
104
105 <Form method="post" className="mt-8 space-y-8">
106 <FieldSet>
107 <FieldLegend>Where its code comes from</FieldLegend>
108 <RadioGroup
109 name="source"
110 value={source}
111 onValueChange={(value) => (value === "github" ? navigate("/new/github") : setSource(value as Source))}
112 aria-label="Where its code comes from"
113 className="gap-3 sm:grid-cols-3"
114 >
115 {sources.map((option) => (
116 <RadioCard key={option.id} value={option.id} title={option.title} description={option.text} icon={option.icon} />
117 ))}
118 </RadioGroup>
119 {loaderData.githubUnknown && <Unavailable what="Importing from GitHub" compact />}
120 </FieldSet>
121
122 {source === "import" && (
123 <Field className="animate-fade-in">
124 <FieldLabel htmlFor="importUrl">Repository to import</FieldLabel>
125 <Input id="importUrl" name="importUrl" type="url" required placeholder="https://github.com/owner/repo" />
126 <FieldDescription>The address of a public repository. Its default branch is copied, up to 40 MB.</FieldDescription>
127 </Field>
128 )}
129
130 <Field>
131 <FieldLabel htmlFor="name">Name</FieldLabel>
132 <InputGroup className="h-10">
133 <input type="hidden" name="workspace" value={workspace} />
134 <InputAddon className="max-w-[55%] shrink-0 gap-2 bg-surface px-2.5 font-medium text-fg sm:max-w-[45%]">
135 <span className="truncate">{loaderData.name}</span>
136 </InputAddon>
137 <InputAddon className="border-l border-line px-2 font-mono text-base text-faint">/</InputAddon>
138 <Input
139 id="name"
140 name="name"
141 required
142 autoFocus
143 maxLength={100}
144 placeholder="my-app"
145 value={name}
146 onChange={(event) => setName(event.target.value)}
147 className="pl-1 font-mono"
148 />
149 </InputGroup>
150 <FieldDescription>
151 The project and its repository share it:{" "}
152 <span className="font-mono text-muted">
153 g1t.sh/{workspace}/<span className={name ? "text-fg" : undefined}>{name || "name"}</span>
154 </span>
155 </FieldDescription>
156 </Field>
157
158 <Field>
159 <FieldLabel htmlFor="description">
160 Description <span className="font-normal text-faint">(optional)</span>
161 </FieldLabel>
162 <Input id="description" name="description" maxLength={200} placeholder="What it is, in a line" />
163 </Field>
164
165 <FieldSet>
166 <FieldLegend>Who can see it</FieldLegend>
167 <RadioGroup name="visibility" defaultValue="public" aria-label="Who can see it" className="gap-3 sm:grid-cols-2">
168 <RadioCard value="public" icon={<Globe />} title="Public" description="Anyone can see its code and open work items." />
169 <RadioCard value="private" icon={<Lock />} title="Private" description="Only members of the workspace." />
170 </RadioGroup>
171 </FieldSet>
172
173 <ErrorText>{actionData?.error}</ErrorText>
174 <SubmitButton variant="accent" pending={source === "import" ? "Importing…" : "Creating…"}>
175 Create project
176 </SubmitButton>
177 </Form>
178 </main>
179 );
180}