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.

375 linesCodeBlameRaw
1import { Box, Lock, Search, Settings } from "lucide-react";
2import { useMemo, useState } from "react";
3import { Form, Link, data, useNavigation, useSearchParams, type ShouldRevalidateFunctionArgs } from "react-router";
4
5import { RESTORE_DAYS } from "@g1t/contracts";
6
7import type { Route } from "./+types/repositories";
8import { page } from "../../lib/meta";
9import { ConfirmDialog } from "../../components/repo-lifecycle";
10import { EmptyState, ErrorText, TimeAgo, notACredential } from "../../components/ui";
11import { Badge } from "../../components/ui/badge";
12import { Button } from "../../components/ui/button";
13import { Card } from "../../components/ui/card";
14import { Checkbox } from "../../components/ui/checkbox";
15import {
16 type BulkResult,
17 REPO_FILTERS,
18 daysUntil,
19 filterCounts,
20 filterRepos,
21 longDate,
22 parseFilter,
23 summariseBulk,
24} from "../../lib/repo-lifecycle";
25import { repos } from "../../lib/services.server";
26import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
27
28export function meta({ params, ...args }: Route.MetaArgs) {
29 return page(args, { title: `Repositories · ${params.owner} · g1t` });
30}
31
32export async function loader({ params, context }: Route.LoaderArgs) {
33 const viewer = getViewer(context);
34 const role = roleIn(viewer, params.owner);
35 // Members only; to anyone else the page does not exist.
36 if (!role) throw data(null, { status: 404 });
37 const owner = role === "owner";
38 const [list, deleted] = await Promise.all([
39 repos.list(viewer, { namespace: params.owner, memberOnly: true }),
40 owner ? repos.deleted(viewer, params.owner) : Promise.resolve([]),
41 ]);
42 return { repos: list, deleted, owner, slug: params.owner.toLowerCase() };
43}
44
45/** Filtering and searching change only the address: the list is already here. */
46export function shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
47 if (!formMethod && currentUrl.pathname === nextUrl.pathname) return false;
48 return defaultShouldRevalidate;
49}
50
51type Outcome = { intent: string; message: string | null; failures: string[]; error: string | null; name?: string };
52
53export async function action({ request, params, context }: Route.ActionArgs): Promise<Outcome> {
54 assertSameOrigin(request);
55 const user = requireUser(context, request);
56 const form = await request.formData();
57 const intent = String(form.get("intent") ?? "");
58 const namespace = params.owner;
59 const name = String(form.get("name") ?? "").trim();
60 const full = `${namespace}/${name}`;
61
62 if (intent === "archive" || intent === "unarchive") {
63 // One at a time, so one refusal does not stop the rest; each says why.
64 const names = [...new Set(form.getAll("repo").map(String).filter(Boolean))];
65 const results: BulkResult[] = [];
66 for (const repo of names) {
67 const done = await repos.archive(user, { namespace, name: repo }, intent === "archive");
68 results.push(done.ok ? { name: repo, ok: true } : { name: repo, ok: false, error: done.error.message });
69 }
70 const summary = summariseBulk(results, intent === "archive" ? "archived" : "unarchived");
71 return { intent, message: summary.message, failures: summary.failures, error: null };
72 }
73 if (intent === "restore") {
74 const restored = await repos.restore(user, { namespace, name });
75 return restored.ok
76 ? { intent, name, message: `Restored ${full}. It is back where it was.`, failures: [], error: null }
77 : { intent, name, message: null, failures: [], error: restored.error.message };
78 }
79 if (intent === "purge") {
80 const purged = await repos.purge(user, { namespace, name }, String(form.get("confirm") ?? "").trim());
81 return purged.ok
82 ? { intent, name, message: `Deleted ${full} for good. Its name is free again.`, failures: [], error: null }
83 : { intent, name, message: null, failures: [], error: purged.error.message };
84 }
85 return { intent, message: null, failures: [], error: "Nothing to do." };
86}
87
88export default function WorkspaceRepositories({ loaderData, actionData }: Route.ComponentProps) {
89 const { repos: list, deleted, owner, slug } = loaderData;
90 const [params, setParams] = useSearchParams();
91 const filter = parseFilter(params.get("filter"));
92 const query = params.get("q") ?? "";
93 const shown = useMemo(() => filterRepos(list, filter, query), [list, filter, query]);
94 const counts = useMemo(() => filterCounts(list), [list]);
95 const [selected, setSelected] = useState<Set<string>>(new Set());
96 const navigation = useNavigation();
97 const bulk = navigation.state !== "idle" ? String(navigation.formData?.get("intent") ?? "") : "";
98 const justDeleted = params.get("deleted");
99
100 // What is selected and still shown: hidden rows are never acted on.
101 const picked = shown.filter((repo) => selected.has(repo.name));
102 const all = shown.length > 0 && picked.length === shown.length;
103 const toggle = (name: string, on: boolean) =>
104 setSelected((current) => {
105 const next = new Set(current);
106 if (on) next.add(name);
107 else next.delete(name);
108 return next;
109 });
110 const update = (changes: Record<string, string | null>) =>
111 setParams(
112 (current) => {
113 const next = new URLSearchParams(current);
114 next.delete("deleted");
115 for (const [key, value] of Object.entries(changes)) {
116 if (value) next.set(key, value);
117 else next.delete(key);
118 }
119 return next;
120 },
121 { replace: true, preventScrollReset: true },
122 );
123 const bulkResult = actionData && (actionData.intent === "archive" || actionData.intent === "unarchive") ? actionData : null;
124 const rowResult = actionData && (actionData.intent === "restore" || actionData.intent === "purge") ? actionData : null;
125
126 return (
127 <div className="space-y-10">
128 {justDeleted && !actionData && (
129 <p role="status" className="rounded-lg border border-accent/30 bg-accent/5 px-4 py-2.5 text-sm">
130 Deleted <span className="font-mono">{slug}/{justDeleted}</span>. You can restore it below for {RESTORE_DAYS} days.
131 </p>
132 )}
133 {rowResult?.message && (
134 <p role="status" className="rounded-lg border border-accent/30 bg-accent/5 px-4 py-2.5 text-sm">
135 {rowResult.message}
136 </p>
137 )}
138
139 <section aria-label="Repositories" className="space-y-4">
140 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
141 <nav aria-label="Filter" className="flex flex-wrap gap-1">
142 {REPO_FILTERS.map((f) => (
143 <button
144 key={f.value}
145 type="button"
146 aria-pressed={filter === f.value}
147 onClick={() => update({ filter: f.value === "all" ? null : f.value })}
148 className={`flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm transition-colors ${
149 filter === f.value ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
150 }`}
151 >
152 {f.label}
153 <span className="text-xs text-faint">{counts[f.value]}</span>
154 </button>
155 ))}
156 </nav>
157 <label className="relative block sm:w-64">
158 <span className="sr-only">Find a repository</span>
159 <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
160 <input
161 type="search"
162 value={query}
163 onChange={(event) => update({ q: event.target.value || null })}
164 placeholder="Find a repository"
165 {...notACredential()}
166 className="w-full rounded-md border border-line bg-bg py-1.5 pr-3 pl-8 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
167 />
168 </label>
169 </div>
170
171 {owner && shown.length > 0 && (
172 <Card asChild radius="lg" className="flex flex-wrap items-center gap-3 px-3 py-2">
173 <Form method="post">
174 {picked.map((repo) => (
175 <input key={repo.name} type="hidden" name="repo" value={repo.name} />
176 ))}
177 <label className="flex items-center gap-2 text-sm text-muted">
178 <Checkbox
179 checked={all ? true : picked.length > 0 ? "indeterminate" : false}
180 onCheckedChange={(on) =>
181 setSelected(on === true && !all ? new Set(shown.map((r) => r.name)) : new Set())
182 }
183 aria-label="Select every repository shown"
184 />
185 {picked.length > 0 ? `${picked.length} selected` : "Select"}
186 </label>
187 <div className="ml-auto flex gap-2">
188 <Button
189 type="submit"
190 name="intent"
191 value="archive"
192 variant="outline"
193 disabled={picked.length === 0 || bulk !== ""}
194 >
195 {bulk === "archive" ? "Archiving…" : "Archive"}
196 </Button>
197 <Button
198 type="submit"
199 name="intent"
200 value="unarchive"
201 variant="outline"
202 disabled={picked.length === 0 || bulk !== ""}
203 >
204 {bulk === "unarchive" ? "Unarchiving…" : "Unarchive"}
205 </Button>
206 </div>
207 </Form>
208 </Card>
209 )}
210 {bulkResult && (
211 <div role="status" className="space-y-1">
212 {bulkResult.message && <p className="text-sm text-muted">{bulkResult.message}</p>}
213 {bulkResult.failures.map((line) => (
214 <ErrorText key={line}>{line}</ErrorText>
215 ))}
216 </div>
217 )}
218
219 {list.length === 0 ? (
220 <EmptyState title="No repositories yet">
221 A repository is made with each new project, or the first time you push to a new address in {slug}.
222 </EmptyState>
223 ) : shown.length === 0 ? (
224 <Card asChild tone="plain" className="border-dashed px-4 py-8 text-center text-sm text-muted">
225 <p>
226 No repositories match.{" "}
227 <Button type="button" variant="link" size="inline" onClick={() => update({ filter: null, q: null })}>
228 Show them all
229 </Button>
230 </p>
231 </Card>
232 ) : (
233 <Card asChild tone="plain" divided className="overflow-hidden">
234 <ul>
235 {shown.map((repo) => {
236 const base = `/${repo.namespace}/${repo.name}`;
237 return (
238 <li key={repo.id} className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-surface/60">
239 {owner && (
240 <Checkbox
241 className="mt-1"
242 checked={selected.has(repo.name)}
243 onCheckedChange={(on) => toggle(repo.name, on === true)}
244 aria-label={`Select ${repo.name}`}
245 />
246 )}
247 <span className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
248 {repo.isPrivate ? <Lock size={13} /> : <Box size={13} />}
249 </span>
250 <div className="min-w-0 grow">
251 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
252 <Link to={base} prefetch="intent" className="truncate font-mono text-sm font-medium hover:underline">
253 {repo.name}
254 </Link>
255 <Badge>{repo.isPrivate ? "private" : "public"}</Badge>
256 {repo.archivedAt && <Badge tone="warn">archived</Badge>}
257 {repo.mirror && (
258 <Badge tone={repo.mirror.state === "takeover" || repo.mirror.state === "handing_back" ? "warn" : "neutral"}>
259 {repo.mirror.state === "takeover" ? "taken over" : repo.mirror.state === "handing_back" ? "handing back" : "mirror"}
260 </Badge>
261 )}
262 </div>
263 {repo.description && <p className="mt-0.5 line-clamp-2 text-sm text-muted">{repo.description}</p>}
264 <p className="mt-1 text-xs text-faint">
265 {repo.archivedAt ? (
266 <>
267 Archived <TimeAgo at={repo.archivedAt} />
268 </>
269 ) : (
270 <>
271 Created <TimeAgo at={repo.createdAt} />
272 </>
273 )}
274 </p>
275 </div>
276 <Link
277 to={`${base}/settings/repository`}
278 className="flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted transition-colors hover:bg-raised hover:text-fg"
279 aria-label={`Settings for ${repo.name}`}
280 >
281 <Settings size={13} />
282 <span className="hidden sm:inline">Settings</span>
283 </Link>
284 </li>
285 );
286 })}
287 </ul>
288 </Card>
289 )}
290 </section>
291
292 {owner && (
293 <section aria-labelledby="recently-deleted" className="space-y-3">
294 <div>
295 <h2 id="recently-deleted" className="font-medium">
296 Recently deleted
297 </h2>
298 <p className="mt-1 text-sm text-muted">
299 Deleted repositories can be restored as they were for {RESTORE_DAYS} days, then they are removed for good.
300 Their names stay taken until then. Only owners see this list.
301 </p>
302 </div>
303 {deleted.length === 0 ? (
304 <Card asChild tone="plain" className="border-dashed px-4 py-6 text-center text-sm text-faint">
305 <p>
306 Nothing has been deleted in the last {RESTORE_DAYS} days.
307 </p>
308 </Card>
309 ) : (
310 <Card asChild tone="plain" divided className="overflow-hidden">
311 <ul>
312 {deleted.map((repo) => {
313 const full = `${repo.namespace}/${repo.name}`;
314 const restoring =
315 navigation.state !== "idle" &&
316 navigation.formData?.get("intent") === "restore" &&
317 navigation.formData?.get("name") === repo.name;
318 const left = daysUntil(repo.purgeAfter);
319 return (
320 <li key={repo.id} className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center">
321 <div className="min-w-0 grow">
322 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
323 <span className="truncate font-mono text-sm font-medium text-muted">
324 {repo.name}
325 </span>
326 <Badge>{repo.isPrivate ? "private" : "public"}</Badge>
327 </div>
328 <p className="mt-1 text-xs text-faint">
329 Deleted by <span className="font-mono text-muted">{repo.deletedBy}</span>{" "}
330 <TimeAgo at={repo.deletedAt} /> · removed for good on {longDate(repo.purgeAfter)}
331 {left <= 7 && ` (${left === 0 ? "today" : left === 1 ? "in a day" : `in ${left} days`})`}
332 </p>
333 {rowResult?.name === repo.name && <ErrorText>{rowResult.error}</ErrorText>}
334 </div>
335 <div className="flex shrink-0 gap-2">
336 <Form method="post">
337 <input type="hidden" name="intent" value="restore" />
338 <input type="hidden" name="name" value={repo.name} />
339 <Button type="submit" variant="outline" disabled={restoring}>
340 {restoring ? "Restoring…" : "Restore"}
341 </Button>
342 </Form>
343 <ConfirmDialog
344 intent="purge"
345 fields={{ name: repo.name }}
346 title={`Delete ${full} permanently?`}
347 description="This cannot be undone."
348 confirm={full}
349 submit="Delete permanently"
350 busy="Deleting…"
351 error={rowResult?.intent === "purge" && rowResult.name === repo.name ? rowResult.error : null}
352 trigger={(open) => (
353 <Button type="button" variant="destructive" onClick={open}>
354 Delete permanently
355 </Button>
356 )}
357 >
358 <li>It is removed for good now, its git data with it. It cannot be restored after this.</li>
359 <li>
360 Its name is free at once: a new repository can be made at{" "}
361 <span className="font-mono text-fg">g1t.sh/{full}</span>.
362 </li>
363 </ConfirmDialog>
364 </div>
365 </li>
366 );
367 })}
368 </ul>
369 </Card>
370 )}
371 </section>
372 )}
373 </div>
374 );
375}