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