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.

370 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 </div>
258 {repo.description && <p className="mt-0.5 line-clamp-2 text-sm text-muted">{repo.description}</p>}
259 <p className="mt-1 text-xs text-faint">
260 {repo.archivedAt ? (
261 <>
262 Archived <TimeAgo at={repo.archivedAt} />
263 </>
264 ) : (
265 <>
266 Created <TimeAgo at={repo.createdAt} />
267 </>
268 )}
269 </p>
270 </div>
271 <Link
272 to={`${base}/settings/repository`}
273 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"
274 aria-label={`Settings for ${repo.name}`}
275 >
276 <Settings size={13} />
277 <span className="hidden sm:inline">Settings</span>
278 </Link>
279 </li>
280 );
281 })}
282 </ul>
283 </Card>
284 )}
285 </section>
286
287 {owner && (
288 <section aria-labelledby="recently-deleted" className="space-y-3">
289 <div>
290 <h2 id="recently-deleted" className="font-medium">
291 Recently deleted
292 </h2>
293 <p className="mt-1 text-sm text-muted">
294 Deleted repositories can be restored as they were for {RESTORE_DAYS} days, then they are removed for good.
295 Their names stay taken until then. Only owners see this list.
296 </p>
297 </div>
298 {deleted.length === 0 ? (
299 <Card asChild tone="plain" className="border-dashed px-4 py-6 text-center text-sm text-faint">
300 <p>
301 Nothing has been deleted in the last {RESTORE_DAYS} days.
302 </p>
303 </Card>
304 ) : (
305 <Card asChild tone="plain" divided className="overflow-hidden">
306 <ul>
307 {deleted.map((repo) => {
308 const full = `${repo.namespace}/${repo.name}`;
309 const restoring =
310 navigation.state !== "idle" &&
311 navigation.formData?.get("intent") === "restore" &&
312 navigation.formData?.get("name") === repo.name;
313 const left = daysUntil(repo.purgeAfter);
314 return (
315 <li key={repo.id} className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center">
316 <div className="min-w-0 grow">
317 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
318 <span className="truncate font-mono text-sm font-medium text-muted">
319 {repo.name}
320 </span>
321 <Badge>{repo.isPrivate ? "private" : "public"}</Badge>
322 </div>
323 <p className="mt-1 text-xs text-faint">
324 Deleted by <span className="font-mono text-muted">{repo.deletedBy}</span>{" "}
325 <TimeAgo at={repo.deletedAt} /> · removed for good on {longDate(repo.purgeAfter)}
326 {left <= 7 && ` (${left === 0 ? "today" : left === 1 ? "in a day" : `in ${left} days`})`}
327 </p>
328 {rowResult?.name === repo.name && <ErrorText>{rowResult.error}</ErrorText>}
329 </div>
330 <div className="flex shrink-0 gap-2">
331 <Form method="post">
332 <input type="hidden" name="intent" value="restore" />
333 <input type="hidden" name="name" value={repo.name} />
334 <Button type="submit" variant="outline" disabled={restoring}>
335 {restoring ? "Restoring…" : "Restore"}
336 </Button>
337 </Form>
338 <ConfirmDialog
339 intent="purge"
340 fields={{ name: repo.name }}
341 title={`Delete ${full} permanently?`}
342 description="This cannot be undone."
343 confirm={full}
344 submit="Delete permanently"
345 busy="Deleting…"
346 error={rowResult?.intent === "purge" && rowResult.name === repo.name ? rowResult.error : null}
347 trigger={(open) => (
348 <Button type="button" variant="destructive" onClick={open}>
349 Delete permanently
350 </Button>
351 )}
352 >
353 <li>It is removed for good now, its git data with it. It cannot be restored after this.</li>
354 <li>
355 Its name is free at once: a new repository can be made at{" "}
356 <span className="font-mono text-fg">g1t.sh/{full}</span>.
357 </li>
358 </ConfirmDialog>
359 </div>
360 </li>
361 );
362 })}
363 </ul>
364 </Card>
365 )}
366 </section>
367 )}
368 </div>
369 );
370}