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.

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