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.

313 linesCodeBlameRaw
1import { LoaderCircle, Plus, Search, Tag, Trash2 } from "lucide-react";
2import { useEffect, useRef, useState } from "react";
3import { Form, Link, useActionData } from "react-router";
4
5import type { Label } from "@g1t/contracts";
6
7import type { Route } from "./+types/labels";
8import { LabelChip } from "../../components/labels";
9import { EmptyState, ErrorText, SubmitButton, usePending } from "../../components/ui";
10import {
11 AlertDialog,
12 AlertDialogAction,
13 AlertDialogCancel,
14 AlertDialogContent,
15 AlertDialogDescription,
16 AlertDialogFooter,
17 AlertDialogHeader,
18 AlertDialogTitle,
19 AlertDialogTrigger,
20} from "../../components/ui/alert-dialog";
21import { Button } from "../../components/ui/button";
22import { Card } from "../../components/ui/card";
23import { refusal, requireRepo, requireWritableRepo } from "../../lib/access.server";
24import { matchLabels, tidyColor } from "../../lib/labels";
25import { page } from "../../lib/meta";
26import { work } from "../../lib/services.server";
27import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
28
29export function meta({ params, ...args }: Route.MetaArgs) {
30 return page(args, { title: `Labels · ${params.owner}/${params.repo} · g1t` });
31}
32
33export async function loader({ params, context }: Route.LoaderArgs) {
34 const { viewer, access } = await requireRepo(context, params, "read");
35 const labels = await work.listLabels({ namespace: params.owner, name: params.repo }, viewer);
36 return { labels: unwrap(labels), canEdit: access.can.manage_labels };
37}
38
39type ActionData = { intent: string; name?: string; error?: string; done?: string };
40
41export async function action({ request, params, context }: Route.ActionArgs): Promise<ActionData> {
42 assertSameOrigin(request);
43 const user = requireUser(context, request);
44 await requireWritableRepo(context, params);
45 const form = await request.formData();
46 const intent = String(form.get("intent") ?? "");
47 const name = String(form.get("name") ?? "");
48 // Labels are made, changed and deleted with the Write role; Triage applies them.
49 const refused = await refusal(context, params, "manage_labels");
50 if (refused) return { intent, name, error: refused };
51 const path = { namespace: params.owner, name: params.repo };
52 const text = (key: string) => String(form.get(key) ?? "");
53 switch (intent) {
54 case "defaults": {
55 const added = await work.addDefaultLabels(user, path);
56 return added.ok ? { intent, done: "The default labels are here." } : { intent, error: added.error.message };
57 }
58 case "delete": {
59 const deleted = await work.deleteLabel(user, path, name);
60 return deleted.ok ? { intent, name } : { intent, name, error: deleted.error.message };
61 }
62 case "create":
63 case "edit": {
64 const saved = await work.saveLabel(user, path, {
65 name: intent === "edit" ? name : undefined,
66 newName: text("newName"),
67 color: text("color"),
68 description: text("description"),
69 });
70 return saved.ok ? { intent, name: saved.value.name } : { intent, name, error: saved.error.message };
71 }
72 default:
73 return { intent, error: "Nothing to do." };
74 }
75}
76
77/** A color's input: a swatch picker and its hex digits, kept together. */
78function ColorField({ defaultValue }: { defaultValue: string }) {
79 const [color, setColor] = useState(tidyColor(defaultValue) ?? "bfdadc");
80 return (
81 <label className="flex items-center gap-2">
82 <span className="sr-only">Color</span>
83 <input
84 type="color"
85 value={`#${color}`}
86 onChange={(event) => setColor(event.target.value.slice(1))}
87 className="size-9 shrink-0 cursor-pointer rounded-md border border-line bg-bg p-1"
88 aria-label="Pick a color"
89 />
90 <input
91 name="color"
92 value={color}
93 onChange={(event) => setColor(event.target.value.replace(/^#/, "").slice(0, 6))}
94 pattern="[0-9a-fA-F]{6}"
95 required
96 aria-label="Color, six hex digits"
97 autoComplete="off"
98 className="h-9 w-24 rounded-md border border-line bg-bg px-2 font-mono text-sm outline-none hover:border-line-strong focus:border-accent-dim"
99 />
100 </label>
101 );
102}
103
104/** Creating a label, or editing one: name, description and color. */
105function LabelForm({ label, onDone }: { label?: Label; onDone: () => void }) {
106 const intent = label ? "edit" : "create";
107 const result = useActionData<ActionData>();
108 const pending = usePending({ intent, name: label?.name ?? "" });
109 const mine = result?.intent === intent && (label ? result.name === label.name : true);
110 // Saved: close the form, once this form's own save has come back.
111 const sent = useRef(false);
112 useEffect(() => {
113 if (sent.current && !pending && mine && !result?.error) onDone();
114 sent.current = pending;
115 }, [mine, result, pending, onDone]);
116 return (
117 <Card asChild className="grid gap-3 p-4 sm:grid-cols-[1fr_1.4fr_auto] sm:items-end">
118 <Form method="post">
119 <input type="hidden" name="intent" value={intent} />
120 <input type="hidden" name="name" value={label?.name ?? ""} />
121 <label className="block">
122 <span className="mb-1.5 block text-xs font-medium text-muted">Name</span>
123 <input
124 name="newName"
125 defaultValue={label?.name ?? ""}
126 required
127 maxLength={50}
128 autoFocus
129 autoComplete="off"
130 data-1p-ignore
131 placeholder="area: cli"
132 className="h-9 w-full rounded-md border border-line bg-bg px-3 text-sm outline-none hover:border-line-strong focus:border-accent-dim"
133 />
134 </label>
135 <label className="block">
136 <span className="mb-1.5 block text-xs font-medium text-muted">Description</span>
137 <input
138 name="description"
139 defaultValue={label?.description ?? ""}
140 maxLength={100}
141 autoComplete="off"
142 data-1p-ignore
143 placeholder="What it means (optional)"
144 className="h-9 w-full rounded-md border border-line bg-bg px-3 text-sm outline-none hover:border-line-strong focus:border-accent-dim"
145 />
146 </label>
147 <div className="flex flex-wrap items-center gap-2">
148 <ColorField defaultValue={label?.color ?? ""} />
149 <Button type="button" variant="outline" onClick={onDone}>
150 Cancel
151 </Button>
152 <SubmitButton match={{ intent, name: label?.name ?? "" }} pending="Saving…">
153 {label ? "Save" : "Create label"}
154 </SubmitButton>
155 </div>
156 {mine && result?.error && (
157 <div className="sm:col-span-3">
158 <ErrorText>{result.error}</ErrorText>
159 </div>
160 )}
161 </Form>
162 </Card>
163 );
164}
165
166function DeleteLabel({ label }: { label: Label }) {
167 const deleting = usePending({ intent: "delete", name: label.name });
168 const carried = label.issues + label.pulls;
169 return (
170 <AlertDialog>
171 <AlertDialogTrigger asChild>
172 <Button type="button" variant="outline" disabled={deleting} aria-label={`Delete ${label.name}`}>
173 {deleting ? <LoaderCircle size={14} className="animate-spin" aria-hidden="true" /> : <Trash2 size={14} />}
174 <span className="hidden sm:inline">{deleting ? "Deleting…" : "Delete"}</span>
175 </Button>
176 </AlertDialogTrigger>
177 <AlertDialogContent>
178 <Form method="post" className="grid gap-4">
179 <input type="hidden" name="intent" value="delete" />
180 <input type="hidden" name="name" value={label.name} />
181 <AlertDialogHeader>
182 <AlertDialogTitle>Delete the {label.name} label?</AlertDialogTitle>
183 <AlertDialogDescription>
184 {carried === 0
185 ? "Nothing carries it. "
186 : `It comes off the ${label.issues} ${label.issues === 1 ? "issue" : "issues"} and ${label.pulls} pull ${label.pulls === 1 ? "request" : "requests"} that carry it. `}
187 This cannot be undone.
188 </AlertDialogDescription>
189 </AlertDialogHeader>
190 <AlertDialogFooter>
191 <AlertDialogCancel>Cancel</AlertDialogCancel>
192 <AlertDialogAction asChild>
193 <button type="submit">
194 <Trash2 size={14} />
195 Delete label
196 </button>
197 </AlertDialogAction>
198 </AlertDialogFooter>
199 </Form>
200 </AlertDialogContent>
201 </AlertDialog>
202 );
203}
204
205export default function Labels({ loaderData, actionData, params }: Route.ComponentProps) {
206 const { labels, canEdit } = loaderData;
207 const base = `/${params.owner}/${params.repo}`;
208 const [query, setQuery] = useState("");
209 const [creating, setCreating] = useState(false);
210 const [editing, setEditing] = useState<string | null>(null);
211 const shown = matchLabels(labels, query);
212 const count = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
213 return (
214 <div className="space-y-4">
215 <div className="flex flex-wrap items-center justify-between gap-3">
216 <div>
217 <h2 className="text-xl font-semibold tracking-tight">Labels</h2>
218 <p className="mt-1 text-sm text-muted">
219 Issues and pull requests carry labels to say what they are. Filter either list by one.
220 </p>
221 </div>
222 {canEdit && !creating && (
223 <div className="flex flex-wrap items-center gap-2">
224 <Form method="post">
225 <input type="hidden" name="intent" value="defaults" />
226 <SubmitButton variant="outline" match={{ intent: "defaults" }} pending="Adding…">
227 Add the default labels
228 </SubmitButton>
229 </Form>
230 <Button type="button" onClick={() => setCreating(true)}>
231 <Plus size={15} />
232 New label
233 </Button>
234 </div>
235 )}
236 </div>
237 {actionData?.intent === "defaults" &&
238 (actionData.error ? <ErrorText>{actionData.error}</ErrorText> : <p className="text-sm text-muted">{actionData.done}</p>)}
239 {actionData?.intent === "delete" && actionData.error && <ErrorText>{actionData.error}</ErrorText>}
240 {creating && <LabelForm onDone={() => setCreating(false)} />}
241
242 <Card tone="plain" className="overflow-hidden">
243 <div className="flex flex-wrap items-center gap-3 border-b border-line bg-surface px-4 py-2.5">
244 <span className="text-sm font-medium">{count(labels.length, "label", "labels")}</span>
245 <label className="ml-auto flex h-8 w-full items-center gap-2 rounded-md border border-line bg-bg px-2.5 sm:w-64">
246 <Search size={14} className="shrink-0 text-faint" />
247 <input
248 type="search"
249 value={query}
250 onChange={(event) => setQuery(event.target.value)}
251 placeholder="Search labels"
252 aria-label="Search labels"
253 autoComplete="off"
254 data-1p-ignore
255 className="w-full bg-transparent text-sm outline-none placeholder:text-faint"
256 />
257 </label>
258 </div>
259 {labels.length === 0 ? (
260 <div className="p-4">
261 <EmptyState title="No labels yet">
262 {canEdit ? "Add the default labels, or make your own." : "Nobody has made a label here yet."}
263 </EmptyState>
264 </div>
265 ) : shown.length === 0 ? (
266 <p className="px-4 py-8 text-center text-sm text-faint">No label matches “{query}”.</p>
267 ) : (
268 <ul className="divide-y divide-line">
269 {shown.map((label) =>
270 editing === label.name ? (
271 <li key={label.name} className="p-3">
272 <LabelForm label={label} onDone={() => setEditing(null)} />
273 </li>
274 ) : (
275 <li key={label.name} className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3">
276 <span className="w-full min-w-0 sm:w-56">
277 <LabelChip name={label.name} color={label.color} />
278 </span>
279 <span className="min-w-0 grow text-sm text-muted">{label.description}</span>
280 <span className="flex shrink-0 items-center gap-3 text-xs text-faint">
281 {label.issues > 0 ? (
282 <Link to={`${base}/issues?label=${encodeURIComponent(label.name)}&state=open`} className="hover:text-fg">
283 {count(label.issues, "issue", "issues")}
284 </Link>
285 ) : (
286 <span>no issues</span>
287 )}
288 {label.pulls > 0 ? (
289 <Link to={`${base}/pulls?label=${encodeURIComponent(label.name)}`} className="hover:text-fg">
290 {count(label.pulls, "pull request", "pull requests")}
291 </Link>
292 ) : (
293 <span>no pull requests</span>
294 )}
295 </span>
296 {canEdit && (
297 <span className="flex shrink-0 items-center gap-2">
298 <Button type="button" variant="outline" onClick={() => setEditing(label.name)} aria-label={`Edit ${label.name}`}>
299 <Tag size={14} />
300 <span className="hidden sm:inline">Edit</span>
301 </Button>
302 <DeleteLabel label={label} />
303 </span>
304 )}
305 </li>
306 ),
307 )}
308 </ul>
309 )}
310 </Card>
311 </div>
312 );
313}