flagon-io/g1t

public

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/apps/web/app/routes/repo/settings-dependencies.tsx

187 lines8,251 bytesCodeBlame
1import { ArrowDownLeft, ArrowUpRight, FileCode2, Network, Trash2 } from "lucide-react";
2import { Form, Link, data, useNavigation } from "react-router";
3
4import type { DependencyLink } from "@g1t/contracts";
5
6import type { Route } from "./+types/settings-dependencies";
7import { page } from "../../lib/meta";
8import { RepoSettingsTabs } from "../../components/repo-settings-tabs";
9import { Avatar, Button, EmptyState, ErrorText, Field, Input } from "../../components/ui";
10import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
11import { projects } from "../../lib/services.server";
12import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
13
14export function meta({ params, ...args }: Route.MetaArgs) {
15 return page(args, { title: `Dependencies · ${params.owner}/${params.repo} · g1t` });
16}
17
18export async function loader({ params, context }: Route.LoaderArgs) {
19 const viewer = getViewer(context);
20 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
21 const [deps, all] = await Promise.all([
22 projects.dependencies(params.owner, params.repo, viewer),
23 projects.list(params.owner, viewer),
24 ]);
25 return { dependencies: unwrap(deps), projects: all.ok ? all.value : [] };
26}
27
28export async function action({ request, params, context }: Route.ActionArgs) {
29 assertSameOrigin(request);
30 const user = requireUser(context, request);
31 const form = await request.formData();
32 const on = String(form.get("on") ?? "");
33 if (form.get("intent") === "remove") {
34 const done = await projects.removeDependency(user, params.owner, params.repo, on);
35 return done.ok ? { notice: `No longer depends on ${on}.` } : { error: done.error.message };
36 }
37 const added = await projects.addDependency(user, params.owner, params.repo, on, String(form.get("as") ?? "") || null);
38 return added.ok ? { notice: `Depends on ${on}.` } : { error: added.error.message };
39}
40
41function Row({ link, base, removable, busy }: { link: DependencyLink; base: string; removable: boolean; busy: boolean }) {
42 return (
43 <li className="flex items-center gap-3 px-4 py-3 text-sm">
44 <Link to={`/${base.split("/")[1]}/${link.slug}`} className="font-medium hover:underline">
45 {link.name}
46 </Link>
47 {link.as ? (
48 <code className="rounded bg-raised px-1.5 py-0.5 font-mono text-xs text-muted">{link.as}</code>
49 ) : (
50 <span className="text-xs text-faint">no variable</span>
51 )}
52 {link.source === "file" && (
53 <span className="inline-flex items-center gap-1 text-xs text-faint" title="Declared in .g1t/project.yml">
54 <FileCode2 size={12} />
55 project.yml
56 </span>
57 )}
58 {removable && link.source === "ui" && (
59 <Form method="post" className="ml-auto">
60 <input type="hidden" name="intent" value="remove" />
61 <input type="hidden" name="on" value={link.slug} />
62 <button
63 type="submit"
64 disabled={busy}
65 aria-label={`Stop depending on ${link.name}`}
66 className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger"
67 >
68 <Trash2 size={14} />
69 </button>
70 </Form>
71 )}
72 </li>
73 );
74}
75
76export default function DependencySettings({ loaderData, actionData, params }: Route.ComponentProps) {
77 const { dependencies, projects: all } = loaderData;
78 const busy = useNavigation().state === "submitting";
79 const base = `/${params.owner}/${params.repo}`;
80 const taken = new Set([params.repo.toLowerCase(), ...dependencies.dependsOn.map((d) => d.slug)]);
81 const choices = all.filter((project) => !taken.has(project.slug));
82 return (
83 <div className="max-w-4xl">
84 <RepoSettingsTabs base={base} />
85 <header>
86 <h2 className="flex items-center gap-2 text-sm font-medium">
87 <Network size={15} className="text-accent" />
88 Dependencies
89 </h2>
90 <p className="mt-1 max-w-2xl text-sm text-muted">
91 The projects this one uses: calls their API, consumes their package. Its builds and its running app get each
92 one's address for the same environment under the variable you name, a preview pointing at the same branch's
93 preview when there is one. Agents working here are told what uses this project.{" "}
94 <a href="https://docs.g1t.sh/guides/projects/#dependencies" className="text-fg hover:underline">
95 How dependencies work
96 </a>
97 </p>
98 </header>
99
100 <div className="mt-4 min-h-6">
101 {actionData && "notice" in actionData && <p className="text-sm text-accent">{actionData.notice}</p>}
102 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
103 </div>
104
105 <section>
106 <h3 className="flex items-center gap-1.5 text-xs font-medium tracking-wide text-muted uppercase">
107 <ArrowUpRight size={13} />
108 Depends on
109 </h3>
110 <div className="mt-2">
111 {dependencies.dependsOn.length === 0 ? (
112 <EmptyState title="Uses no other project" />
113 ) : (
114 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
115 {dependencies.dependsOn.map((link) => (
116 <Row key={link.slug} link={link} base={base} removable busy={busy} />
117 ))}
118 </ul>
119 )}
120 </div>
121 {choices.length > 0 && (
122 <Form method="post" className="mt-4 grid items-end gap-3 rounded-xl border border-line bg-surface p-4 sm:grid-cols-[1fr_1fr_auto]">
123 <input type="hidden" name="intent" value="add" />
124 <Field label="Project">
125 <Select name="on" required defaultValue={choices[0].slug}>
126 <SelectTrigger aria-label="Project" className="h-auto py-2">
127 <SelectValue />
128 </SelectTrigger>
129 <SelectContent>
130 {choices.map((project) => (
131 <SelectItem
132 key={project.slug}
133 value={project.slug}
134 icon={<Avatar name={project.name} size={16} square />}
135 description={project.slug !== project.name ? project.slug : undefined}
136 >
137 {project.name}
138 </SelectItem>
139 ))}
140 </SelectContent>
141 </Select>
142 </Field>
143 <Field label="Its address as">
144 <Input name="as" placeholder="API_URL" className="font-mono" />
145 </Field>
146 <Button type="submit" disabled={busy}>
147 Add dependency
148 </Button>
149 </Form>
150 )}
151 </section>
152
153 <section className="mt-10">
154 <h3 className="flex items-center gap-1.5 text-xs font-medium tracking-wide text-muted uppercase">
155 <ArrowDownLeft size={13} />
156 Used by
157 </h3>
158 <div className="mt-2">
159 {dependencies.usedBy.length === 0 ? (
160 <EmptyState title="No project uses this one yet" />
161 ) : (
162 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
163 {dependencies.usedBy.map((link) => (
164 <Row key={link.slug} link={link} base={base} removable={false} busy={busy} />
165 ))}
166 </ul>
167 )}
168 </div>
169 </section>
170
171 <section className="mt-10 rounded-xl border border-line p-5 text-sm">
172 <h3 className="flex items-center gap-2 font-medium">
173 <FileCode2 size={15} className="text-faint" />
174 Or declare them in the code
175 </h3>
176 <p className="mt-1 text-muted">
177 A <code className="text-fg">.g1t/project.yml</code> in the project's root directory is read on every push to
178 the default branch, and its dependencies replace the ones it declared before:
179 </p>
180 <pre className="mt-3 overflow-x-auto rounded-lg border border-line bg-bg p-3 font-mono text-xs text-muted">{`dependsOn:
181 - project: api
182 as: API_URL
183 - project: ui-kit`}</pre>
184 </section>
185 </div>
186 );
187}