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.

362 linesCodeBlameRaw
1import { Check, ChevronDown, Milestone as MilestoneIcon, Plus, Settings2, Tag, X } from "lucide-react";
2import { type ReactNode, useEffect, useMemo, useState } from "react";
3import { Link, useFetcher, useNavigate } from "react-router";
4
5import type { Label as RepoLabel, Milestone, MilestoneRef } from "@g1t/contracts";
6
7import { chipStyle, matchLabels, percentDone, tidyColor, tidyLabelName } from "../lib/labels";
8import { cn } from "../lib/cn";
9import { ErrorText } from "./ui";
10import { Button } from "./ui/button";
11import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "./ui/command";
12import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
13
14/** A label as a colored chip. Without a known color it is neutral. */
15export function LabelChip({ name, color, className }: { name: string; color?: string | null; className?: string }) {
16 const tinted = tidyColor(color) != null;
17 return (
18 <span
19 style={chipStyle(color)}
20 className={cn(
21 "inline-flex max-w-full shrink-0 items-center truncate rounded-full border px-2 py-px text-xs font-medium",
22 !tinted && "border-line text-muted",
23 className,
24 )}
25 >
26 {name}
27 </span>
28 );
29}
30
31/** A swatch of a label's color, for lists and pickers. */
32export function Swatch({ color }: { color?: string | null }) {
33 const hex = tidyColor(color);
34 return (
35 <span
36 aria-hidden="true"
37 className="inline-block size-3 shrink-0 rounded-full border border-line-strong"
38 style={hex ? { backgroundColor: `#${hex}`, borderColor: `color-mix(in oklab, #${hex} 70%, var(--color-fg))` } : undefined}
39 />
40 );
41}
42
43/** How far along a milestone is, as a bar. */
44export function MilestoneBar({ milestone, className }: { milestone: Milestone; className?: string }) {
45 const percent = percentDone(milestone);
46 return (
47 <span
48 role="progressbar"
49 aria-valuemin={0}
50 aria-valuemax={100}
51 aria-valuenow={percent}
52 aria-label={`${percent}% complete`}
53 className={cn("block h-2 overflow-hidden rounded-full bg-line", className)}
54 >
55 <span className="block h-full rounded-full bg-success transition-[width]" style={{ width: `${percent}%` }} />
56 </span>
57 );
58}
59
60/**
61 * The sidebar's Labels: the item's chips, and for someone who may change
62 * them, a menu to tick labels on and off, find one, or make one. It saves
63 * when the menu closes, posting `action=labels` with each `label`.
64 */
65export function LabelsBox({
66 labels,
67 chosen,
68 canEdit,
69 canCreate,
70 manageUrl,
71}: {
72 labels: RepoLabel[];
73 chosen: string[];
74 canEdit: boolean;
75 canCreate: boolean;
76 /** The labels page, for someone who may manage them. */
77 manageUrl?: string;
78}) {
79 const fetcher = useFetcher<{ error?: string } | null>();
80 const [open, setOpen] = useState(false);
81 const [picked, setPicked] = useState<string[]>(chosen);
82 const [query, setQuery] = useState("");
83 // What the page says now, once a save has come back.
84 useEffect(() => {
85 if (!open) setPicked(chosen);
86 }, [chosen, open]);
87 const colors = useMemo(() => new Map(labels.map((label) => [label.name, label.color])), [labels]);
88 const typed = tidyLabelName(query);
89 const offerNew = canCreate && typed !== "" && !labels.some((label) => label.name === typed) && !picked.includes(typed);
90 const toggle = (name: string) => setPicked((now) => (now.includes(name) ? now.filter((other) => other !== name) : [...now, name]));
91 const save = (next: string[]) => {
92 const same = next.length === chosen.length && next.every((name) => chosen.includes(name));
93 if (same) return;
94 const form = new FormData();
95 form.set("action", "labels");
96 for (const name of next) form.append("label", name);
97 fetcher.submit(form, { method: "post" });
98 };
99 // While saving, show what was picked.
100 const shown = fetcher.state !== "idle" ? picked : chosen;
101 return (
102 <section>
103 <div className="flex items-center justify-between gap-2">
104 <h3 className="text-sm font-medium">Labels</h3>
105 {canEdit && (
106 <Popover
107 open={open}
108 onOpenChange={(next) => {
109 setOpen(next);
110 if (!next) {
111 setQuery("");
112 save(picked);
113 }
114 }}
115 >
116 <PopoverTrigger asChild>
117 <Button type="button" aria-label="Edit labels" variant="ghost" size="inline" className="p-1 text-faint">
118 <Settings2 size={15} />
119 </Button>
120 </PopoverTrigger>
121 <PopoverContent align="end" className="w-72 p-0">
122 <Command shouldFilter={false}>
123 <CommandInput value={query} onValueChange={setQuery} placeholder="Find or make a label" />
124 <CommandList>
125 {!offerNew && <CommandEmpty>No label matches.</CommandEmpty>}
126 <CommandGroup heading="Labels">
127 {matchLabels(labels, query).map((label) => (
128 <CommandItem key={label.name} value={label.name} onSelect={() => toggle(label.name)}>
129 <span className="flex size-4 items-center justify-center">{picked.includes(label.name) && <Check className="text-accent" />}</span>
130 <Swatch color={label.color} />
131 <span className="min-w-0 grow">
132 <span className="block truncate">{label.name}</span>
133 {label.description && <span className="block truncate text-xs text-faint">{label.description}</span>}
134 </span>
135 </CommandItem>
136 ))}
137 {picked
138 .filter((name) => !colors.has(name) && name.includes(typed))
139 .map((name) => (
140 <CommandItem key={name} value={`new:${name}`} onSelect={() => toggle(name)}>
141 <span className="flex size-4 items-center justify-center">
142 <Check className="text-accent" />
143 </span>
144 <Swatch />
145 <span className="grow truncate">{name}</span>
146 <span className="text-xs text-faint">new</span>
147 </CommandItem>
148 ))}
149 </CommandGroup>
150 {offerNew && (
151 <CommandGroup heading="New label">
152 <CommandItem
153 value={`create:${typed}`}
154 onSelect={() => {
155 toggle(typed);
156 setQuery("");
157 }}
158 >
159 <Plus />
160 <span className="truncate">
161 Create <span className="font-medium">{typed}</span>
162 </span>
163 </CommandItem>
164 </CommandGroup>
165 )}
166 </CommandList>
167 </Command>
168 {manageUrl && (
169 <Link to={manageUrl} className="flex items-center gap-2 border-t border-line px-3 py-2 text-xs text-muted hover:text-fg">
170 <Tag size={13} />
171 Edit labels
172 </Link>
173 )}
174 </PopoverContent>
175 </Popover>
176 )}
177 </div>
178 <div className="mt-2 flex flex-wrap gap-1.5">
179 {shown.length === 0 ? (
180 <span className="text-xs text-faint">None yet</span>
181 ) : (
182 shown.map((name) => <LabelChip key={name} name={name} color={colors.get(name)} />)
183 )}
184 </div>
185 {fetcher.data?.error && (
186 <div className="mt-2">
187 <ErrorText>{fetcher.data.error}</ErrorText>
188 </div>
189 )}
190 </section>
191 );
192}
193
194/**
195 * The sidebar's Milestone: the one the item is in, with its progress, and
196 * for someone with the Triage role a menu to move it, posting
197 * `action=milestone` with `milestone` (0 for none).
198 */
199export function MilestoneBox({
200 milestones,
201 current,
202 canEdit,
203 base,
204}: {
205 milestones: Milestone[];
206 current: MilestoneRef | null | undefined;
207 canEdit: boolean;
208 /** The repository's address, for links to milestones. */
209 base: string;
210}) {
211 const fetcher = useFetcher<{ error?: string } | null>();
212 const [open, setOpen] = useState(false);
213 const choose = (number: number) => {
214 setOpen(false);
215 if (number === (current?.number ?? 0)) return;
216 const form = new FormData();
217 form.set("action", "milestone");
218 form.set("milestone", String(number));
219 fetcher.submit(form, { method: "post" });
220 };
221 const pending = fetcher.state !== "idle" ? Number(fetcher.formData?.get("milestone") ?? 0) : null;
222 const shownNumber = pending ?? current?.number ?? 0;
223 const shown = milestones.find((milestone) => milestone.number === shownNumber);
224 const title = shown?.title ?? (shownNumber === current?.number ? current?.title : undefined);
225 return (
226 <section>
227 <div className="flex items-center justify-between gap-2">
228 <h3 className="text-sm font-medium">Milestone</h3>
229 {canEdit && (
230 <Popover open={open} onOpenChange={setOpen}>
231 <PopoverTrigger asChild>
232 <Button type="button" aria-label="Choose a milestone" variant="ghost" size="inline" className="p-1 text-faint">
233 <Settings2 size={15} />
234 </Button>
235 </PopoverTrigger>
236 <PopoverContent align="end" className="w-72 p-0">
237 <Command>
238 <CommandInput placeholder="Find a milestone" />
239 <CommandList>
240 <CommandEmpty>No milestone matches.</CommandEmpty>
241 {current && (
242 <CommandGroup>
243 <CommandItem value="none" onSelect={() => choose(0)}>
244 <X />
245 Clear milestone
246 </CommandItem>
247 </CommandGroup>
248 )}
249 <CommandGroup heading="Open">
250 {milestones
251 .filter((milestone) => milestone.state === "open")
252 .map((milestone) => (
253 <CommandItem key={milestone.number} value={`${milestone.title} ${milestone.number}`} onSelect={() => choose(milestone.number)}>
254 <span className="flex size-4 items-center justify-center">
255 {milestone.number === current?.number && <Check className="text-accent" />}
256 </span>
257 <span className="min-w-0 grow truncate">{milestone.title}</span>
258 {milestone.dueOn && <span className="text-xs text-faint">{milestone.dueOn}</span>}
259 </CommandItem>
260 ))}
261 </CommandGroup>
262 </CommandList>
263 </Command>
264 <Link to={`${base}/milestones`} className="flex items-center gap-2 border-t border-line px-3 py-2 text-xs text-muted hover:text-fg">
265 <MilestoneIcon size={13} />
266 All milestones
267 </Link>
268 </PopoverContent>
269 </Popover>
270 )}
271 </div>
272 <div className="mt-2">
273 {shownNumber && title ? (
274 <Link to={`${base}/milestones/${shownNumber}`} className="group block">
275 {shown && <MilestoneBar milestone={shown} className="mb-1.5" />}
276 <span className="flex items-center gap-1.5 text-sm group-hover:text-accent">
277 <MilestoneIcon size={14} className="shrink-0 text-faint" />
278 <span className="truncate">{title}</span>
279 </span>
280 </Link>
281 ) : (
282 <span className="text-xs text-faint">None</span>
283 )}
284 </div>
285 {fetcher.data?.error && (
286 <div className="mt-2">
287 <ErrorText>{fetcher.data.error}</ErrorText>
288 </div>
289 )}
290 </section>
291 );
292}
293
294/**
295 * A list's filter: a button that opens a searchable menu of choices, each
296 * a link to the list filtered that way.
297 */
298export function FilterMenu({
299 label,
300 active,
301 options,
302 clearTo,
303 searchPlaceholder,
304 emptyText,
305}: {
306 label: string;
307 /** What it is filtered by now, shown on the button. */
308 active?: ReactNode;
309 options: { key: string; to: string; label: ReactNode; keywords: string; selected?: boolean }[];
310 /** Where "Clear" goes, when something is chosen. */
311 clearTo?: string;
312 searchPlaceholder: string;
313 emptyText: string;
314}) {
315 const [open, setOpen] = useState(false);
316 const navigate = useNavigate();
317 const go = (to: string) => {
318 setOpen(false);
319 navigate(to);
320 };
321 return (
322 <Popover open={open} onOpenChange={setOpen}>
323 <PopoverTrigger asChild>
324 <button
325 type="button"
326 className={cn(
327 "inline-flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-sm transition-colors",
328 active ? "border-accent/40 bg-accent/5 text-fg" : "border-line text-muted hover:border-line-strong hover:text-fg",
329 )}
330 >
331 {label}
332 {active && <span className="max-w-32 truncate font-medium">{active}</span>}
333 <ChevronDown size={14} className="text-faint" />
334 </button>
335 </PopoverTrigger>
336 <PopoverContent align="start" className="w-72 p-0">
337 <Command>
338 <CommandInput placeholder={searchPlaceholder} />
339 <CommandList>
340 <CommandEmpty>{emptyText}</CommandEmpty>
341 {clearTo && (
342 <CommandGroup>
343 <CommandItem value="clear-filter" onSelect={() => go(clearTo)}>
344 <X />
345 Clear
346 </CommandItem>
347 </CommandGroup>
348 )}
349 <CommandGroup>
350 {options.map((option) => (
351 <CommandItem key={option.key} value={option.keywords} onSelect={() => go(option.to)}>
352 <span className="flex size-4 items-center justify-center">{option.selected && <Check className="text-accent" />}</span>
353 {option.label}
354 </CommandItem>
355 ))}
356 </CommandGroup>
357 </CommandList>
358 </Command>
359 </PopoverContent>
360 </Popover>
361 );
362}