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.

388 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) return loaderData.member ? <ChooseRegistry workspace={workspace} /> : <EmptyState title="No public packages">This workspace has no packages you can view yet.</EmptyState>;
110 if (view === "deleted") {
111 if (isFailed(deleted)) return <Unavailable what={deleted.what} />;
112 return <DeletedPackages workspace={workspace} deleted={deleted} outcome={actionData as Outcome | undefined} />;
113 }
114 const filters: Filters = { type: ecosystem, q: query, visibility, sort };
115 const filtered = ecosystem != null || query != null || visibility !== "all";
116 return (
117 <div className="space-y-4">
118 <div className="flex flex-wrap items-center gap-2">
119 <FilterMenu
120 label="Type"
121 value={ecosystem ? ECOSYSTEM_LABEL[ecosystem] : "All"}
122 options={[null, ...ECOSYSTEMS].map((option) => ({
123 label: option ? ECOSYSTEM_LABEL[option] : "All",
124 to: hrefWith(workspace, filters, { type: option }),
125 current: ecosystem === option,
126 }))}
127 />
128 <Form method="get" className="relative min-w-48 grow">
129 <label className="sr-only" htmlFor="package-search">
130 Search packages
131 </label>
132 <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
133 <input
134 id="package-search"
135 name="q"
136 defaultValue={query ?? ""}
137 placeholder="Search packages"
138 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"
139 />
140 {ecosystem && <input type="hidden" name="type" value={ecosystem} />}
141 {visibility !== "all" && <input type="hidden" name="visibility" value={visibility} />}
142 {sort !== "updated" && <input type="hidden" name="sort" value={sort} />}
143 </Form>
144 <FilterMenu
145 label="Visibility"
146 value={VISIBILITY_LABEL[visibility]}
147 options={(["all", "public", "private"] as const).map((option) => ({
148 label: VISIBILITY_LABEL[option],
149 to: hrefWith(workspace, filters, { visibility: option }),
150 current: visibility === option,
151 }))}
152 />
153 <FilterMenu
154 label="Sort"
155 value={SORT_LABEL[sort]}
156 options={(["updated", "downloads", "name"] as const).map((option) => ({
157 label: SORT_LABEL[option],
158 to: hrefWith(workspace, filters, { sort: option }),
159 current: sort === option,
160 }))}
161 />
162 </div>
163
164 <Card asChild className="overflow-hidden">
165 <section>
166 <header className="flex items-center justify-between gap-3 border-b border-line px-4 py-2.5 text-sm">
167 <span className="font-medium">
168 {list.length} {list.length === 1 ? "package" : "packages"}
169 {filtered && (
170 <Link to={`/${workspace}/-/packages`} className="ml-3 text-xs font-normal text-muted hover:text-fg">
171 Clear filters
172 </Link>
173 )}
174 </span>
175 <span className="flex items-center gap-4">
176 {!isFailed(deleted) && deleted.length > 0 && (
177 <Link to={`/${workspace}/-/packages?view=deleted`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
178 <Trash2 size={12} />
179 Deleted packages <span className="rounded-full bg-raised px-1.5 text-faint tabular-nums">{deleted.length}</span>
180 </Link>
181 )}
182 <a href={`${DOCS}/guides/packages/`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
183 How packages work <ArrowUpRight size={12} />
184 </a>
185 </span>
186 </header>
187 {list.length === 0 ? (
188 <div className="p-4">
189 <EmptyState title="No packages match">Try another type or visibility, or clear the search.</EmptyState>
190 </div>
191 ) : (
192 <ul className="divide-y divide-line">
193 {list.map((pkg) => (
194 <PackageRow key={pkg.id} pkg={pkg} />
195 ))}
196 </ul>
197 )}
198 </section>
199 </Card>
200 </div>
201 );
202}
203
204/** A filter as a button that opens its choices; each choice is a link, so the address says the view. */
205function FilterMenu({ label, value, options }: { label: string; value: string; options: { label: string; to: string; current: boolean }[] }) {
206 return (
207 <DropdownMenu>
208 <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">
209 <span className="text-muted">{label}:</span>
210 <span className="font-medium">{value}</span>
211 <ChevronDown size={13} className="text-faint" />
212 </DropdownMenuTrigger>
213 <DropdownMenuContent align="start" className="min-w-44">
214 {options.map((option) => (
215 <DropdownMenuItem key={option.label} asChild>
216 <Link to={option.to} preventScrollReset>
217 <Check size={14} className={cn(!option.current && "invisible")} />
218 {option.label}
219 </Link>
220 </DropdownMenuItem>
221 ))}
222 </DropdownMenuContent>
223 </DropdownMenu>
224 );
225}
226
227function PackageRow({ pkg }: { pkg: PackageSummary }) {
228 return (
229 <li>
230 <Link
231 to={`/${pkg.workspace}/-/packages/${pkg.ecosystem}/${pkg.name}`}
232 className="flex items-center gap-3.5 px-4 py-3.5 transition-colors hover:bg-raised/50"
233 >
234 <PackageIcon ecosystem={pkg.ecosystem} />
235 <span className="min-w-0 grow">
236 <span className="flex flex-wrap items-center gap-2">
237 <span className="truncate font-medium">{pkg.name}</span>
238 {pkg.visibility === "private" && (
239 <Badge>
240 <Lock size={10} />
241 Private
242 </Badge>
243 )}
244 </span>
245 <span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
246 <span>{ECOSYSTEM_LABEL[pkg.ecosystem]}</span>
247 <span className="text-faint">·</span>
248 <span>
249 Updated <TimeAgo at={pkg.updated_at} />
250 </span>
251 {pkg.repo && (
252 <>
253 <span className="text-faint">·</span>
254 <span className="inline-flex items-center gap-1">
255 <Box size={11} />
256 {pkg.repo.name}
257 </span>
258 </>
259 )}
260 {pkg.description && (
261 <>
262 <span className="text-faint">·</span>
263 <span className="max-w-md truncate">{pkg.description}</span>
264 </>
265 )}
266 </span>
267 </span>
268 <span className="hidden shrink-0 items-center gap-5 text-xs text-muted tabular-nums sm:flex">
269 {pkg.latest && <span className="max-w-32 truncate font-mono text-fg-soft">{pkg.latest}</span>}
270 <span className="w-16 text-right">{formatBytes(pkg.size)}</span>
271 <Hint label={`${pkg.downloads.toLocaleString("en-US")} downloads`}>
272 <span className="inline-flex w-14 items-center justify-end gap-1">
273 <Download size={12} />
274 {shortCount(pkg.downloads)}
275 <span className="sr-only"> downloads</span>
276 </span>
277 </Hint>
278 </span>
279 </Link>
280 </li>
281 );
282}
283
284/** Deleted packages that can still be restored, for those who administer them. */
285function DeletedPackages({ workspace, deleted, outcome }: { workspace: string; deleted: PackageSummary[]; outcome: Outcome | undefined }) {
286 return (
287 <div className="space-y-4">
288 <Link to={`/${workspace}/-/packages`} className="text-sm text-muted hover:text-fg">
289 Packages
290 </Link>
291 <div className="max-w-2xl">
292 <h2 className="text-lg font-semibold tracking-tight">Deleted packages</h2>
293 <p className="mt-1 text-sm text-muted">
294 Packages deleted in the last {PACKAGE_RESTORE_DAYS} days, with every version they had. Restore one to bring it back as it was;
295 until it is purged, nobody else can publish a package of its name.
296 </p>
297 </div>
298 {outcome?.error && <ErrorText>{outcome.error}</ErrorText>}
299 {deleted.length === 0 ? (
300 <EmptyState title="No deleted packages">Packages you delete stay here, restorable, for {PACKAGE_RESTORE_DAYS} days.</EmptyState>
301 ) : (
302 <Card asChild divided className="overflow-hidden">
303 <ul>
304 {deleted.map((pkg) => (
305 <li key={pkg.id} className="flex flex-wrap items-center gap-x-3.5 gap-y-2 px-4 py-3.5">
306 <PackageIcon ecosystem={pkg.ecosystem} />
307 <span className="min-w-0 grow basis-48">
308 <span className="block truncate font-medium">{pkg.name}</span>
309 <span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
310 <span>{ECOSYSTEM_LABEL[pkg.ecosystem]}</span>
311 <span className="text-faint">·</span>
312 <span>
313 {pkg.versions} {pkg.versions === 1 ? "version" : "versions"}
314 </span>
315 {pkg.deleted_at && (
316 <>
317 <span className="text-faint">·</span>
318 <span>
319 Deleted {pkg.deleted_by ? `by ${pkg.deleted_by} ` : ""}
320 <TimeAgo at={pkg.deleted_at} />
321 </span>
322 </>
323 )}
324 {pkg.purge_at && (
325 <>
326 <span className="text-faint">·</span>
327 <span>Purged {new Date(pkg.purge_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })}</span>
328 </>
329 )}
330 </span>
331 </span>
332 <Form method="post">
333 <input type="hidden" name="intent" value="restore" />
334 <input type="hidden" name="ecosystem" value={pkg.ecosystem} />
335 <input type="hidden" name="name" value={pkg.name} />
336 <SubmitButton variant="outline" match={{ intent: "restore", name: pkg.name }} pending="Restoring…">
337 <RotateCcw size={14} />
338 Restore
339 </SubmitButton>
340 </Form>
341 </li>
342 ))}
343 </ul>
344 </Card>
345 )}
346 </div>
347 );
348}
349
350/** With no packages yet: every registry, what it is for, and the first step. */
351function ChooseRegistry({ workspace }: { workspace: string }) {
352 return (
353 <div className="space-y-6">
354 <div className="max-w-2xl">
355 <h2 className="text-lg font-semibold tracking-tight">Choose a registry</h2>
356 <p className="mt-1 text-sm text-muted">
357 Packages live beside the code that makes them, with the same members, roles and tokens. Workflows publish with{" "}
358 <code className="font-mono text-xs">G1T_TOKEN</code>.
359 </p>
360 </div>
361 <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
362 {REGISTRIES.map((registry) => (
363 <Card asChild key={registry.ecosystem} className="flex flex-col p-4">
364 <li>
365 <div className="flex items-center gap-2.5">
366 <PackageIcon ecosystem={registry.ecosystem} />
367 <span className="font-medium">{ECOSYSTEM_LABEL[registry.ecosystem]}</span>
368 {!registry.ready && <Badge>Soon</Badge>}
369 </div>
370 <p className="mt-2.5 grow text-sm text-muted">{registry.blurb}</p>
371 {registry.ready && (
372 <div className="mt-3">
373 <CopyLine prompt text={registry.start.replaceAll("<workspace>", workspace)} />
374 </div>
375 )}
376 <a
377 href={`${DOCS}${registry.guide}`}
378 className="mt-3 inline-flex items-center gap-1 self-start text-sm text-fg-soft hover:text-fg"
379 >
380 {registry.ready ? "Set it up" : "What's planned"} <ArrowUpRight size={13} />
381 </a>
382 </li>
383 </Card>
384 ))}
385 </ul>
386 </div>
387 );
388}