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
382 linesCodeBlameRaw
1import { ArrowUpRight, Box, Check, ChevronDown, Download, Lock, RotateCcw, Search, Trash2 } from "lucide-react";
2import { Form, Link, data, redirect } from "react-router";
3
4import { ECOSYSTEMS, type Ecosystem, PACKAGE_RESTORE_DAYS, type PackageSummary } from "@g1t/contracts";
5
6import type { Route } from "./+types/packages";
7import { PackageIcon } from "../../components/package-icon";
8import { CopyLine, EmptyState, ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
9import { Badge } from "../../components/ui/badge";
10import { Card } from "../../components/ui/card";
11import { Hint } from "../../components/ui/hint";
12import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu";
13import { cn } from "../../lib/cn";
14import { page } from "../../lib/meta";
15import {
16 ECOSYSTEM_LABEL,
17 type PackageSort,
18 REGISTRIES,
19 SORT_LABEL,
20 VISIBILITY_LABEL,
21 type VisibilityFilter,
22 arrange,
23 formatBytes,
24 shortCount,
25} from "../../lib/packages";
26import { packages } from "../../lib/services.server";
27import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
28
29const DOCS = "https://docs.g1t.sh";
30
31export function meta({ params, ...args }: Route.MetaArgs) {
32 return page(args, { title: `Packages · ${params.owner} · g1t` });
33}
34
35function pick<T extends string>(value: string | null, options: readonly T[], fallback: T): T {
36 return value && (options as readonly string[]).includes(value) ? (value as T) : fallback;
37}
38
39export async function loader({ params, context, request }: Route.LoaderArgs) {
40 const viewer = getViewer(context);
41 const member = roleIn(viewer, params.owner) != null;
42 const url = new URL(request.url);
43 const type = url.searchParams.get("type");
44 const ecosystem = type && (ECOSYSTEMS as readonly string[]).includes(type) ? (type as Ecosystem) : null;
45 const query = url.searchParams.get("q")?.trim() || null;
46 const visibility = pick<VisibilityFilter>(url.searchParams.get("visibility"), ["all", "public", "private"], "all");
47 const sort = pick<PackageSort>(url.searchParams.get("sort"), ["updated", "downloads", "name"], "updated");
48 const view = member && url.searchParams.get("view") === "deleted" ? "deleted" : "active";
49 // Deleted packages are listed for those who administer them, and only
50 // then is there a Deleted packages view.
51 const [found, deletedFound] = await Promise.all([
52 packages.list(params.owner, viewer, { ecosystem, query }),
53 member ? packages.deleted(params.owner, viewer) : null,
54 ]);
55 const listed = unwrap(found);
56 const deleted = deletedFound?.ok ? deletedFound.value : [];
57 // Whether the workspace has any at all decides between a filtered-out
58 // list and the first-package page.
59 const any = listed.length > 0 || deleted.length > 0 || ecosystem != null || query != null;
60 return {
61 member,
62 list: arrange(listed, visibility, sort),
63 deleted,
64 view,
65 any,
66 ecosystem,
67 query,
68 visibility,
69 sort,
70 workspace: params.owner.toLowerCase(),
71 };
72}
73
74type Outcome = { error: string | null; message: string | null };
75
76/** Restoring a deleted package, from the Deleted packages view. */
77export async function action({ request, params, context }: Route.ActionArgs): Promise<Outcome | Response> {
78 assertSameOrigin(request);
79 const user = requireUser(context, request);
80 const form = await request.formData();
81 if (form.get("intent") !== "restore") return { error: "That is not something this page does.", message: null };
82 const type = String(form.get("ecosystem") ?? "");
83 const name = String(form.get("name") ?? "");
84 if (!(ECOSYSTEMS as readonly string[]).includes(type) || !name) return { error: "Name the package to restore.", message: null };
85 const restored = await packages.restorePackage(user, params.owner, type as Ecosystem, name, "web");
86 if (!restored.ok) return { error: restored.error.message, message: null };
87 return redirect(`/${params.owner}/-/packages/${type}/${name}`);
88}
89
90type Filters = { type: string | null; q: string | null; visibility: VisibilityFilter; sort: PackageSort };
91
92/** The page's address with one filter changed, defaults left out. */
93function hrefWith(workspace: string, current: Filters, change: Partial<Filters>): string {
94 const next = { ...current, ...change };
95 const params = new URLSearchParams();
96 if (next.type) params.set("type", next.type);
97 if (next.q) params.set("q", next.q);
98 if (next.visibility !== "all") params.set("visibility", next.visibility);
99 if (next.sort !== "updated") params.set("sort", next.sort);
100 return `/${workspace}/-/packages${params.size ? `?${params}` : ""}`;
101}
102
103/** The packages a workspace publishes: filtered, sorted, or, with none yet, how to start. */
104export default function Packages({ loaderData, actionData }: Route.ComponentProps) {
105 const { list, deleted, view, any, ecosystem, query, visibility, sort, workspace } = loaderData;
106 if (!any) return loaderData.member ? <ChooseRegistry workspace={workspace} /> : <EmptyState title="No public packages">This workspace has no packages you can view yet.</EmptyState>;
107 if (view === "deleted") return <DeletedPackages workspace={workspace} deleted={deleted} outcome={actionData as Outcome | undefined} />;
108 const filters: Filters = { type: ecosystem, q: query, visibility, sort };
109 const filtered = ecosystem != null || query != null || visibility !== "all";
110 return (
111 <div className="space-y-4">
112 <div className="flex flex-wrap items-center gap-2">
113 <FilterMenu
114 label="Type"
115 value={ecosystem ? ECOSYSTEM_LABEL[ecosystem] : "All"}
116 options={[null, ...ECOSYSTEMS].map((option) => ({
117 label: option ? ECOSYSTEM_LABEL[option] : "All",
118 to: hrefWith(workspace, filters, { type: option }),
119 current: ecosystem === option,
120 }))}
121 />
122 <Form method="get" className="relative min-w-48 grow">
123 <label className="sr-only" htmlFor="package-search">
124 Search packages
125 </label>
126 <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
127 <input
128 id="package-search"
129 name="q"
130 defaultValue={query ?? ""}
131 placeholder="Search packages"
132 className="h-9 w-full rounded-lg border border-line bg-surface pr-3 pl-8 text-sm placeholder:text-faint focus:border-line-strong focus:outline-none"
133 />
134 {ecosystem && <input type="hidden" name="type" value={ecosystem} />}
135 {visibility !== "all" && <input type="hidden" name="visibility" value={visibility} />}
136 {sort !== "updated" && <input type="hidden" name="sort" value={sort} />}
137 </Form>
138 <FilterMenu
139 label="Visibility"
140 value={VISIBILITY_LABEL[visibility]}
141 options={(["all", "public", "private"] as const).map((option) => ({
142 label: VISIBILITY_LABEL[option],
143 to: hrefWith(workspace, filters, { visibility: option }),
144 current: visibility === option,
145 }))}
146 />
147 <FilterMenu
148 label="Sort"
149 value={SORT_LABEL[sort]}
150 options={(["updated", "downloads", "name"] as const).map((option) => ({
151 label: SORT_LABEL[option],
152 to: hrefWith(workspace, filters, { sort: option }),
153 current: sort === option,
154 }))}
155 />
156 </div>
157
158 <Card asChild className="overflow-hidden">
159 <section>
160 <header className="flex items-center justify-between gap-3 border-b border-line px-4 py-2.5 text-sm">
161 <span className="font-medium">
162 {list.length} {list.length === 1 ? "package" : "packages"}
163 {filtered && (
164 <Link to={`/${workspace}/-/packages`} className="ml-3 text-xs font-normal text-muted hover:text-fg">
165 Clear filters
166 </Link>
167 )}
168 </span>
169 <span className="flex items-center gap-4">
170 {deleted.length > 0 && (
171 <Link to={`/${workspace}/-/packages?view=deleted`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
172 <Trash2 size={12} />
173 Deleted packages <span className="rounded-full bg-raised px-1.5 text-faint tabular-nums">{deleted.length}</span>
174 </Link>
175 )}
176 <a href={`${DOCS}/guides/packages/`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
177 How packages work <ArrowUpRight size={12} />
178 </a>
179 </span>
180 </header>
181 {list.length === 0 ? (
182 <div className="p-4">
183 <EmptyState title="No packages match">Try another type or visibility, or clear the search.</EmptyState>
184 </div>
185 ) : (
186 <ul className="divide-y divide-line">
187 {list.map((pkg) => (
188 <PackageRow key={pkg.id} pkg={pkg} />
189 ))}
190 </ul>
191 )}
192 </section>
193 </Card>
194 </div>
195 );
196}
197
198/** A filter as a button that opens its choices; each choice is a link, so the address says the view. */
199function FilterMenu({ label, value, options }: { label: string; value: string; options: { label: string; to: string; current: boolean }[] }) {
200 return (
201 <DropdownMenu>
202 <DropdownMenuTrigger className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-line bg-surface px-3 text-sm transition-colors hover:border-line-strong data-[state=open]:border-line-strong">
203 <span className="text-muted">{label}:</span>
204 <span className="font-medium">{value}</span>
205 <ChevronDown size={13} className="text-faint" />
206 </DropdownMenuTrigger>
207 <DropdownMenuContent align="start" className="min-w-44">
208 {options.map((option) => (
209 <DropdownMenuItem key={option.label} asChild>
210 <Link to={option.to} preventScrollReset>
211 <Check size={14} className={cn(!option.current && "invisible")} />
212 {option.label}
213 </Link>
214 </DropdownMenuItem>
215 ))}
216 </DropdownMenuContent>
217 </DropdownMenu>
218 );
219}
220
221function PackageRow({ pkg }: { pkg: PackageSummary }) {
222 return (
223 <li>
224 <Link
225 to={`/${pkg.workspace}/-/packages/${pkg.ecosystem}/${pkg.name}`}
226 className="flex items-center gap-3.5 px-4 py-3.5 transition-colors hover:bg-raised/50"
227 >
228 <PackageIcon ecosystem={pkg.ecosystem} />
229 <span className="min-w-0 grow">
230 <span className="flex flex-wrap items-center gap-2">
231 <span className="truncate font-medium">{pkg.name}</span>
232 {pkg.visibility === "private" && (
233 <Badge>
234 <Lock size={10} />
235 Private
236 </Badge>
237 )}
238 </span>
239 <span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
240 <span>{ECOSYSTEM_LABEL[pkg.ecosystem]}</span>
241 <span className="text-faint">·</span>
242 <span>
243 Updated <TimeAgo at={pkg.updated_at} />
244 </span>
245 {pkg.repo && (
246 <>
247 <span className="text-faint">·</span>
248 <span className="inline-flex items-center gap-1">
249 <Box size={11} />
250 {pkg.repo.name}
251 </span>
252 </>
253 )}
254 {pkg.description && (
255 <>
256 <span className="text-faint">·</span>
257 <span className="max-w-md truncate">{pkg.description}</span>
258 </>
259 )}
260 </span>
261 </span>
262 <span className="hidden shrink-0 items-center gap-5 text-xs text-muted tabular-nums sm:flex">
263 {pkg.latest && <span className="max-w-32 truncate font-mono text-fg-soft">{pkg.latest}</span>}
264 <span className="w-16 text-right">{formatBytes(pkg.size)}</span>
265 <Hint label={`${pkg.downloads.toLocaleString("en-US")} downloads`}>
266 <span className="inline-flex w-14 items-center justify-end gap-1">
267 <Download size={12} />
268 {shortCount(pkg.downloads)}
269 <span className="sr-only"> downloads</span>
270 </span>
271 </Hint>
272 </span>
273 </Link>
274 </li>
275 );
276}
277
278/** Deleted packages that can still be restored, for those who administer them. */
279function DeletedPackages({ workspace, deleted, outcome }: { workspace: string; deleted: PackageSummary[]; outcome: Outcome | undefined }) {
280 return (
281 <div className="space-y-4">
282 <Link to={`/${workspace}/-/packages`} className="text-sm text-muted hover:text-fg">
283 Packages
284 </Link>
285 <div className="max-w-2xl">
286 <h2 className="text-lg font-semibold tracking-tight">Deleted packages</h2>
287 <p className="mt-1 text-sm text-muted">
288 Packages deleted in the last {PACKAGE_RESTORE_DAYS} days, with every version they had. Restore one to bring it back as it was;
289 until it is purged, nobody else can publish a package of its name.
290 </p>
291 </div>
292 {outcome?.error && <ErrorText>{outcome.error}</ErrorText>}
293 {deleted.length === 0 ? (
294 <EmptyState title="No deleted packages">Packages you delete stay here, restorable, for {PACKAGE_RESTORE_DAYS} days.</EmptyState>
295 ) : (
296 <Card asChild divided className="overflow-hidden">
297 <ul>
298 {deleted.map((pkg) => (
299 <li key={pkg.id} className="flex flex-wrap items-center gap-x-3.5 gap-y-2 px-4 py-3.5">
300 <PackageIcon ecosystem={pkg.ecosystem} />
301 <span className="min-w-0 grow basis-48">
302 <span className="block truncate font-medium">{pkg.name}</span>
303 <span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
304 <span>{ECOSYSTEM_LABEL[pkg.ecosystem]}</span>
305 <span className="text-faint">·</span>
306 <span>
307 {pkg.versions} {pkg.versions === 1 ? "version" : "versions"}
308 </span>
309 {pkg.deleted_at && (
310 <>
311 <span className="text-faint">·</span>
312 <span>
313 Deleted {pkg.deleted_by ? `by ${pkg.deleted_by} ` : ""}
314 <TimeAgo at={pkg.deleted_at} />
315 </span>
316 </>
317 )}
318 {pkg.purge_at && (
319 <>
320 <span className="text-faint">·</span>
321 <span>Purged {new Date(pkg.purge_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })}</span>
322 </>
323 )}
324 </span>
325 </span>
326 <Form method="post">
327 <input type="hidden" name="intent" value="restore" />
328 <input type="hidden" name="ecosystem" value={pkg.ecosystem} />
329 <input type="hidden" name="name" value={pkg.name} />
330 <SubmitButton variant="outline" match={{ intent: "restore", name: pkg.name }} pending="Restoring…">
331 <RotateCcw size={14} />
332 Restore
333 </SubmitButton>
334 </Form>
335 </li>
336 ))}
337 </ul>
338 </Card>
339 )}
340 </div>
341 );
342}
343
344/** With no packages yet: every registry, what it is for, and the first step. */
345function ChooseRegistry({ workspace }: { workspace: string }) {
346 return (
347 <div className="space-y-6">
348 <div className="max-w-2xl">
349 <h2 className="text-lg font-semibold tracking-tight">Choose a registry</h2>
350 <p className="mt-1 text-sm text-muted">
351 Packages live beside the code that makes them, with the same members, roles and tokens. Workflows publish with{" "}
352 <code className="font-mono text-xs">G1T_TOKEN</code>.
353 </p>
354 </div>
355 <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
356 {REGISTRIES.map((registry) => (
357 <Card asChild key={registry.ecosystem} className="flex flex-col p-4">
358 <li>
359 <div className="flex items-center gap-2.5">
360 <PackageIcon ecosystem={registry.ecosystem} />
361 <span className="font-medium">{ECOSYSTEM_LABEL[registry.ecosystem]}</span>
362 {!registry.ready && <Badge>Soon</Badge>}
363 </div>
364 <p className="mt-2.5 grow text-sm text-muted">{registry.blurb}</p>
365 {registry.ready && (
366 <div className="mt-3">
367 <CopyLine prompt text={registry.start.replaceAll("<workspace>", workspace)} />
368 </div>
369 )}
370 <a
371 href={`${DOCS}${registry.guide}`}
372 className="mt-3 inline-flex items-center gap-1 self-start text-sm text-fg-soft hover:text-fg"
373 >
374 {registry.ready ? "Set it up" : "What's planned"} <ArrowUpRight size={13} />
375 </a>
376 </li>
377 </Card>
378 ))}
379 </ul>
380 </div>
381 );
382}