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.

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