g1t/apps/web/app/routes/workspace/repositories.tsx

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