Skip to content

Compare changes

Choose two branches to see what one has that the other does not, then open a pull request for it.

Open a pull request

1 commit

74 files+2242−4630/74 viewed
+32−2
11 ---
22 title: Workspaces
3−description: Workspaces, members and roles, and access tokens that belong to a workspace.
3+description: Workspaces, their names and icons, members and roles, and access tokens that belong to a workspace.
44 ---
55
66 A workspace owns repositories and is the first part of their address:
3737 create a workspace with that name, and nobody can register a username that
3838 is already a workspace.
3939
40+## Display name, slug and icon
41+
42+A workspace has two names:
43+
44+| | Example | Where it appears | Changes |
45+| --- | --- | --- | --- |
46+| **Display name** | `Flagon Industries` | The sidebar, the top of its page, mission control and link previews | Any time, up to 80 characters; spaces and capitals are fine |
47+| **Slug** | `flagon` | Every address: `g1t.sh/flagon/<repo>` and clone URLs | Never, so links and clones keep working |
48+
49+Without a display name, the slug is shown. Where an address is shown, the
50+slug is in monospace beside the name. Owners change the display name and
51+description on **Settings → General**; from the API, `update_workspace`.
52+
53+A workspace also has an icon, as an organization does on GitHub. Without
54+one, g1t draws its first letter in a colour of its own. To upload one, an
55+owner opens **Settings → General** and picks an image:
56+
57+- PNG, JPEG, WebP or GIF, at most 1 MB. Square images look best.
58+- An image is checked by its contents, not its name. SVG is refused,
59+ because it can carry script.
60+- **Remove** goes back to the letter.
61+
62+The icon then shows wherever the workspace does, and on its link previews
63+(PNG and JPEG icons only). Each image is served from
64+`g1t.sh/avatars/<sha256>`, an address named after its contents, so an icon
65+that changes gets a new address and nothing shows the old one.
66+
67+You can upload a picture of yourself the same way, under
68+[Settings → Picture](https://g1t.sh/settings#picture).
69+
4070 ## Members and roles
4171
4272 | Role | Can |
5888
5989 | Settings | Who | |
6090 | --- | --- | --- |
61−| **General** | Owners | The display name and a one-line description. |
91+| **General** | Owners | The icon, the display name and a one-line description. The slug is shown, and cannot be changed. |
6292 | **Members** | Members | Who belongs, and their roles. Owners add and remove people. |
6393 | **Access tokens** | Members | The workspace's own tokens. Owners create and delete them. |
6494 | **Billing and plans** | Members | [Plans](/guides/usage-and-billing/#plans), the balance and the statement. Owners turn plans on and add credit. |
+48−0
2727
2828 --animate-fade-up: fade-up 0.6s ease-out both;
2929 --animate-dash: dash 1.2s linear infinite;
30+
31+ /* Menus, popovers and dialogs (components/ui): in quickly, out quicker. */
32+ --animate-pop-in: pop-in 0.16s cubic-bezier(0.16, 1, 0.3, 1);
33+ --animate-pop-out: pop-out 0.1s ease-in forwards;
34+ --animate-fade-in: fade-in 0.16s ease-out;
35+ --animate-fade-out: fade-out 0.12s ease-in forwards;
36+ --animate-dialog-in: dialog-in 0.2s cubic-bezier(0.16, 1, 0.3, 1);
37+ --animate-dialog-out: dialog-out 0.12s ease-in forwards;
38+}
39+
40+@keyframes pop-in {
41+ from {
42+ opacity: 0;
43+ transform: scale(0.96) translateY(-2px);
44+ }
45+}
46+
47+@keyframes pop-out {
48+ to {
49+ opacity: 0;
50+ transform: scale(0.96);
51+ }
52+}
53+
54+@keyframes fade-in {
55+ from {
56+ opacity: 0;
57+ }
58+}
59+
60+@keyframes fade-out {
61+ to {
62+ opacity: 0;
63+ }
64+}
65+
66+@keyframes dialog-in {
67+ from {
68+ opacity: 0;
69+ transform: translateY(6px) scale(0.97);
70+ }
71+}
72+
73+@keyframes dialog-out {
74+ to {
75+ opacity: 0;
76+ transform: translateY(6px) scale(0.97);
77+ }
3078 }
3179
3280 @keyframes fade-up {
+73−0
1+import { ImageUp, Trash2 } from "lucide-react";
2+import { useRef } from "react";
3+import { Form, useNavigation } from "react-router";
4+
5+import { AVATAR_ACCEPT } from "../lib/avatar-upload";
6+import { Avatar, Button, ErrorText } from "./ui";
7+
8+/**
9+ * Uploading an avatar, as GitHub's settings do: the one shown now, a
10+ * button to pick a new image, which is sent as soon as it is picked, and
11+ * a way back to the letter. Posts `intent=avatar` with the file in
12+ * `avatar`, or `intent=remove-avatar`.
13+ */
14+export function AvatarField({
15+ name,
16+ image,
17+ square,
18+ error,
19+ about,
20+}: {
21+ /** What the letter avatar is drawn from. */
22+ name: string;
23+ image?: string | null;
24+ square?: boolean;
25+ error?: string;
26+ about: string;
27+}) {
28+ const navigation = useNavigation();
29+ const form = useRef<HTMLFormElement>(null);
30+ const intent = navigation.formData?.get("intent");
31+ const busy = navigation.state === "submitting" && (intent === "avatar" || intent === "remove-avatar");
32+ return (
33+ <div className="flex items-start gap-4">
34+ <Avatar name={name} image={image} size={64} square={square} />
35+ <div className="min-w-0 space-y-2">
36+ <p className="text-sm text-muted">{about}</p>
37+ <div className="flex flex-wrap items-center gap-2">
38+ <Form method="post" encType="multipart/form-data" ref={form}>
39+ <input type="hidden" name="intent" value="avatar" />
40+ <label
41+ className={`inline-flex cursor-pointer items-center gap-2 rounded-md px-3.5 py-2 text-sm font-medium ring-1 ring-line transition-colors hover:ring-line-strong ${
42+ busy ? "pointer-events-none opacity-50" : ""
43+ }`}
44+ >
45+ <ImageUp size={15} />
46+ {busy && intent === "avatar" ? "Uploading…" : image ? "Upload a new image" : "Upload an image"}
47+ <input
48+ type="file"
49+ name="avatar"
50+ accept={AVATAR_ACCEPT}
51+ className="sr-only"
52+ onChange={(event) => {
53+ if (event.currentTarget.files?.length) form.current?.requestSubmit();
54+ }}
55+ />
56+ </label>
57+ </Form>
58+ {image && (
59+ <Form method="post">
60+ <input type="hidden" name="intent" value="remove-avatar" />
61+ <Button type="submit" variant="quiet" disabled={busy}>
62+ <Trash2 size={15} />
63+ Remove
64+ </Button>
65+ </Form>
66+ )}
67+ </div>
68+ <p className="text-xs text-faint">PNG, JPEG, WebP or GIF, at most 1 MB. Square images look best.</p>
69+ <ErrorText>{error}</ErrorText>
70+ </div>
71+ </div>
72+ );
73+}
+4−4
2222 } from "../lib/diff";
2323 import { Markdown } from "./markdown";
2424 import { Avatar, Button, EmptyState, Textarea, TimeAgo } from "./ui";
25+import { Checkbox } from "./ui/checkbox";
2526
2627 const ROW_STYLES: Record<DiffLine["kind"], string> = {
2728 context: "",
440441 viewed ? "border-accent/40 bg-accent/10 text-accent" : "border-line text-muted hover:text-fg"
441442 }`}
442443 >
443− <input
444− type="checkbox"
444+ <Checkbox
445445 checked={viewed}
446− onChange={(event) => onViewed(event.target.checked)}
447− className="accent-accent"
446+ onCheckedChange={(checked) => onViewed(checked === true)}
447+ className="size-3.5 rounded-[4px] [&_svg]:size-2.5"
448448 />
449449 Viewed
450450 </label>
+7−1
1515 import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
1616 import remarkGfm from "remark-gfm";
1717
18+import { Checkbox } from "./ui/checkbox";
1819 import { type AlertKind, type MarkdownRepo, rehypeAlerts, rehypeReferences } from "../lib/markdown-plugins";
1920
2021 /** The text inside a React tree, for anchors and copying. */
225226 input({ type, checked, disabled }) {
226227 // Task list boxes: shown, not editable.
227228 return type === "checkbox" ? (
228− <input type="checkbox" checked={checked} disabled={disabled} readOnly className="mr-1.5 translate-y-0.5 accent-accent" />
229+ <Checkbox
230+ checked={checked === true}
231+ disabled={disabled !== false}
232+ aria-label={checked ? "Done" : "Not done"}
233+ className="mr-1.5 inline-flex translate-y-0.5 disabled:cursor-default disabled:opacity-100"
234+ />
229235 ) : null;
230236 },
231237 img({ src, alt }) {
+53−20
33 * fields each one asks for, and the routing of each kind of work to a
44 * provider and model.
55 */
6−import { Bot, ChevronRight, Webhook } from "lucide-react";
6+import { Bot, ChevronRight, Sparkles, Webhook } from "lucide-react";
77 import { type ReactNode, useState } from "react";
88 import { Form, Link } from "react-router";
99
1010 import { type Connection, MODEL_TASKS, type ModelRoute, type ModelTask, type Provider, PROVIDERS } from "@g1t/contracts";
1111
1212 import { Button, Field, Input } from "./ui";
13+import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select";
1314
1415 type Group = "labs" | "platforms" | "any";
1516
244245 );
245246 }
246247
248+/** Radix Select items cannot be "": the routing value for "same as everything". */
249+const SAME = "same";
250+
247251 const SELECT =
248252 "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none hover:border-line-strong focus:border-accent-dim disabled:opacity-60";
249253
289293 <Input name="secret" type="password" />
290294 </Field>
291295 <Field label="Send the key as">
292− <select name="authHeader" className={SELECT} defaultValue={anthropic ? "x-api-key" : "authorization"}>
293− <option value="x-api-key">x-api-key</option>
294− <option value="authorization">Authorization: Bearer</option>
295− </select>
296+ <Select name="authHeader" defaultValue={anthropic ? "x-api-key" : "authorization"}>
297+ <SelectTrigger aria-label="Send the key as" className="font-mono text-[0.8125rem]">
298+ <SelectValue />
299+ </SelectTrigger>
300+ <SelectContent>
301+ <SelectItem value="x-api-key" description="Anthropic's header" className="font-mono text-[0.8125rem]">
302+ x-api-key
303+ </SelectItem>
304+ <SelectItem value="authorization" description="OpenAI's header, and most others" className="font-mono text-[0.8125rem]">
305+ Authorization: Bearer
306+ </SelectItem>
307+ </SelectContent>
308+ </Select>
296309 </Field>
297310 {GATEWAY_TOKEN}
298311 <Field
399412 <p className="text-xs text-faint">{TASK_LABELS[task].hint}</p>
400413 </div>
401414 <input type="hidden" name={`route-${task}`} value={value} />
402− <select
403− aria-label={`${TASK_LABELS[task].label}: provider`}
415+ <Select
404416 disabled={!owner}
405− value={choice.target}
406− onChange={(event) => set(task, { target: event.target.value, model: "" })}
407− className={SELECT}
417+ value={choice.target || SAME}
418+ onValueChange={(target) => set(task, { target: target === SAME ? "" : target, model: "" })}
408419 >
409− {task !== "default" && <option value="">Same as everything</option>}
410− <option value="g1t" disabled={!hostedOpen}>
411− g1t's models{hostedOpen ? ` · credit at cost + ${marginPercent}%` : " · not open to this workspace yet"}
412− </option>
413− {connections.map((c) => (
414− <option key={c.id} value={c.id}>
415− {c.name}
416− </option>
417− ))}
418− </select>
420+ <SelectTrigger aria-label={`${TASK_LABELS[task].label}: provider`}>
421+ <SelectValue />
422+ </SelectTrigger>
423+ <SelectContent>
424+ {task !== "default" && (
425+ <>
426+ <SelectItem value={SAME} description="Follows the choice for everything">
427+ Same as everything
428+ </SelectItem>
429+ <SelectSeparator />
430+ </>
431+ )}
432+ <SelectItem
433+ value="g1t"
434+ disabled={!hostedOpen}
435+ icon={<Sparkles />}
436+ description={hostedOpen ? `Credit at cost + ${marginPercent}%` : "Not open to this workspace yet"}
437+ >
438+ g1t's models
439+ </SelectItem>
440+ {connections.length > 0 && (
441+ <SelectGroup>
442+ <SelectLabel>Your providers</SelectLabel>
443+ {connections.map((c) => (
444+ <SelectItem key={c.id} value={c.id} icon={<Bot />} description={PROVIDERS[c.provider]?.label}>
445+ {c.name}
446+ </SelectItem>
447+ ))}
448+ </SelectGroup>
449+ )}
450+ </SelectContent>
451+ </Select>
419452 <div>
420453 <input
421454 aria-label={`${TASK_LABELS[task].label}: model`}
+81−92
1313
1414 import type { SecretsAction, SecretsData } from "../lib/secrets.server";
1515 import { Button, ButtonLink, EmptyState, ErrorText, TimeAgo } from "./ui";
16+import { CheckboxOption } from "./ui/checkbox";
17+import { RadioCard, RadioGroup, RadioOption } from "./ui/radio-group";
18+import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select";
1619
1720 /** The environments every deployment knows; workflow jobs may name others. */
1821 const KNOWN_ENVIRONMENTS = ["production", "preview"];
100103 className={`${SELECT} w-full pl-9`}
101104 />
102105 </label>
103− <select value={type} onChange={(e) => setType(e.target.value)} aria-label="Type" className={SELECT}>
104− <option value="all">All types</option>
105− <option value="secret">Secret</option>
106− <option value="variable">Config</option>
107− </select>
108− <select value={environment} onChange={(e) => setEnvironment(e.target.value)} aria-label="Environment" className={SELECT}>
109− <option value="all">All environments</option>
110− {environments.map((env) => (
111− <option key={env} value={env}>
112− {environmentsLabel([env])}
113− </option>
114− ))}
115− </select>
106+ <Select value={type} onValueChange={setType}>
107+ <SelectTrigger aria-label="Type" className="h-auto w-auto py-2">
108+ <SelectValue />
109+ </SelectTrigger>
110+ <SelectContent align="end">
111+ <SelectItem value="all">All types</SelectItem>
112+ <SelectSeparator />
113+ <SelectItem value="secret" icon={<Lock />}>Secret</SelectItem>
114+ <SelectItem value="variable" icon={<SlidersHorizontal />}>Config</SelectItem>
115+ </SelectContent>
116+ </Select>
117+ <Select value={environment} onValueChange={setEnvironment}>
118+ <SelectTrigger aria-label="Environment" className="h-auto w-auto py-2">
119+ <SelectValue />
120+ </SelectTrigger>
121+ <SelectContent align="end">
122+ <SelectItem value="all">All environments</SelectItem>
123+ {environments.length > 0 && <SelectSeparator />}
124+ {environments.map((env) => (
125+ <SelectItem key={env} value={env}>
126+ {environmentsLabel([env])}
127+ </SelectItem>
128+ ))}
129+ </SelectContent>
130+ </Select>
116131 </div>
117132
118133 <ErrorText>{data.error}</ErrorText>
238253
239254 <fieldset>
240255 <legend className="mb-2 text-sm font-medium text-muted">Type</legend>
241− <div className="grid gap-3 sm:grid-cols-2">
256+ <RadioGroup
257+ name="type"
258+ value={type}
259+ onValueChange={(value) => setType(value as typeof type)}
260+ aria-label="Type"
261+ className="gap-3 sm:grid-cols-2"
262+ >
242263 {(
243264 [
244− ["secret", "Secret", "You can't read it again after saving. For passwords, API keys and tokens."],
245− ["config", "Config", "Readable by members after saving. For values that are not sensitive."],
265+ ["secret", "Secret", "You can't read it again after saving. For passwords, API keys and tokens.", <Lock key="i" />],
266+ ["config", "Config", "Readable by members after saving. For values that are not sensitive.", <SlidersHorizontal key="i" />],
246267 ] as const
247− ).map(([value, title, text]) => {
268+ ).map(([value, title, text, icon]) => (
248269 // A secret's value is sealed: it can never become config.
249− const locked = value === "config" && row?.kind === "secret";
250− return (
251− <label
252− key={value}
253− className={`rounded-xl border p-3.5 transition-colors ${
254− type === value ? "border-accent bg-accent/5" : "border-line hover:border-line-strong"
255− } ${locked ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}
256− >
257− <span className="flex items-center justify-between">
258− <span className="text-sm font-medium">{title}</span>
259− <input
260− type="radio"
261− name="type"
262− value={value}
263− checked={type === value}
264− disabled={locked}
265− onChange={() => setType(value)}
266− className="accent-accent"
267− />
268− </span>
269− <span className="mt-1 block text-xs text-muted">{text}</span>
270− </label>
271− );
272− })}
273− </div>
270+ <RadioCard
271+ key={value}
272+ value={value}
273+ title={title}
274+ description={text}
275+ icon={icon}
276+ disabled={value === "config" && row?.kind === "secret"}
277+ />
278+ ))}
279+ </RadioGroup>
274280 {row?.kind === "variable" && (
275281 <p className="mt-2 text-xs text-faint">Config can become a secret; a secret cannot become config.</p>
276282 )}
316322 <fieldset>
317323 <legend className="mb-2 text-sm font-medium text-muted">Environments</legend>
318324 <div className="space-y-2 text-sm">
319− <label className="flex items-center gap-2">
320− <input type="radio" name="scope" value="all" checked={!some} onChange={() => setSome(false)} className="accent-accent" />
321− All environments
322− </label>
323− <label className="flex items-center gap-2">
324− <input type="radio" name="scope" value="some" checked={some} onChange={() => setSome(true)} className="accent-accent" />
325− Only some
326− </label>
325+ <RadioGroup name="scope" value={some ? "some" : "all"} onValueChange={(value) => setSome(value === "some")} aria-label="Environments">
326+ <RadioOption value="all" label="All environments" />
327+ <RadioOption value="some" label="Only some" />
328+ </RadioGroup>
327329 {some && (
328330 <div className="ml-6 space-y-2">
329331 {KNOWN_ENVIRONMENTS.map((env) => (
330− <label key={env} className="flex items-center gap-2">
331− <input
332− type="checkbox"
333− name="env"
334− value={env}
335− defaultChecked={row?.environments.includes(env)}
336− className="accent-accent"
337− />
338− {environmentsLabel([env])}
339− </label>
332+ <CheckboxOption
333+ key={env}
334+ name="env"
335+ value={env}
336+ defaultChecked={row?.environments.includes(env)}
337+ label={environmentsLabel([env])}
338+ />
340339 ))}
341340 <input
342341 name="envCustom"
362361 ["deployments", "Deployments", "The build's environment, and env.KEY in the running app"],
363362 ] as const
364363 ).map(([value, title, text]) => (
365− <label key={value} className="flex items-start gap-2">
366− <input
367− type="checkbox"
368− name="availableTo"
369− value={value}
370− defaultChecked={row ? row.availableTo.includes(value) : true}
371− className="mt-1 accent-accent"
372− />
373− <span>
374− {title}
375− <span className="block text-xs text-faint">{text}</span>
376− </span>
377− </label>
364+ <CheckboxOption
365+ key={value}
366+ name="availableTo"
367+ value={value}
368+ defaultChecked={row ? row.availableTo.includes(value) : true}
369+ label={title}
370+ description={text}
371+ />
378372 ))}
379373 </div>
380374 </fieldset>
383377 <fieldset>
384378 <legend className="mb-2 text-sm font-medium text-muted">Projects</legend>
385379 <div className="space-y-2 text-sm">
386− <label className="flex items-center gap-2">
387− <input type="radio" name="reach" value="all" checked={reach === "all"} onChange={() => setReach("all")} className="accent-accent" />
388− Every project
389− </label>
390− <label className="flex items-center gap-2">
391− <input type="radio" name="reach" value="some" checked={reach === "some"} onChange={() => setReach("some")} className="accent-accent" />
392− Only these
393− </label>
380+ <RadioGroup name="reach" value={reach} onValueChange={(value) => setReach(value as typeof reach)} aria-label="Projects">
381+ <RadioOption value="all" label="Every project" />
382+ <RadioOption value="some" label="Only these" />
383+ </RadioGroup>
394384 {reach === "some" && (
395− <div className="ml-6 grid max-h-48 gap-1.5 overflow-y-auto sm:grid-cols-2">
385+ <div className="ml-6 grid max-h-48 gap-1.5 overflow-y-auto p-0.5 sm:grid-cols-2">
396386 {projects.map((name) => (
397− <label key={name} className="flex items-center gap-2 font-mono text-xs">
398− <input
399− type="checkbox"
400− name="project"
401− value={name}
402− defaultChecked={row?.projects.includes(name)}
403− className="accent-accent"
404− />
405− {name}
406− </label>
387+ <CheckboxOption
388+ key={name}
389+ name="project"
390+ value={name}
391+ defaultChecked={row?.projects.includes(name)}
392+ label={name}
393+ className="items-center"
394+ labelClassName="font-mono text-xs"
395+ />
407396 ))}
408397 </div>
409398 )}
+36−24
4141 import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
4242 import { Form, Link, NavLink, useLocation, useNavigate, useNavigation, useSubmit } from "react-router";
4343
44−import type { User } from "@g1t/contracts";
44+import type { Membership, User } from "@g1t/contracts";
4545 import { MICROS_PER_DOLLAR } from "@g1t/contracts";
4646
4747 import { Mark } from "./logo";
5959 /** What the sidebar needs, worked out by the root loader for a signed-in person. */
6060 export type ShellData = {
6161 /** The workspace the sidebar is about: the one being looked at, or their first. */
62− workspace: { slug: string; role: "owner" | "member" } | null;
62+ workspace: Membership | null;
6363 /** Its projects, by name: `name` is the slug in their address. */
6464 repos: { namespace: string; name: string; title?: string; isPrivate: boolean }[];
6565 /** The project being looked at, if any, whoever owns it. */
213213 );
214214 }
215215
216+/** What a workspace is called where people read it: its display name, else its slug. */
217+function displayName(membership: Membership): string {
218+ return membership.name?.trim() || membership.slug;
219+}
220+
216221 function WorkspaceSwitcher({ user, shell }: { user: User; shell: ShellData }) {
217− const current = shell.workspace?.slug;
222+ const workspace = shell.workspace;
223+ const current = workspace?.slug;
224+ const label = workspace ? displayName(workspace) : "Choose a workspace";
218225 return (
219226 <DropdownMenu>
220− {/* The name goes to the workspace; only the arrows switch it. */}
227+ {/* The name goes to the workspace; only the arrows switch it. The
228+ name gets all the room there is, and the whole of it on hover. */}
221229 <Link
222230 to={current ? `/${current}` : "/workspaces/new"}
223− className="flex h-8 min-w-0 items-center gap-2 rounded-md px-1.5 transition-colors hover:bg-raised"
231+ title={workspace ? `${label} · g1t.sh/${workspace.slug}` : undefined}
232+ className="flex h-8 min-w-0 grow items-center gap-1.5 rounded-md px-1 transition-colors hover:bg-raised"
224233 >
225− {current ? (
226− <Avatar name={current} size={20} square />
234+ {workspace ? (
235+ <Avatar name={workspace.slug} image={workspace.avatar} size={20} square />
227236 ) : (
228− <span className="size-5 rounded-md border border-dashed border-line-strong" />
237+ <span className="size-5 shrink-0 rounded-md border border-dashed border-line-strong" />
229238 )}
230− <span className="min-w-0 truncate text-sm font-medium">{current ?? "Choose a workspace"}</span>
231− {shell.workspace && (
232− <span className="shrink-0 rounded-full bg-raised px-1.5 py-px text-[0.625rem] font-medium text-muted ring-1 ring-line capitalize">
233− {shell.workspace.role}
234− </span>
235− )}
239+ <span className="min-w-0 truncate text-[0.8125rem] font-medium">{label}</span>
236240 </Link>
237241 <DropdownMenuTrigger
238242 aria-label="Switch workspace"
239− className="ml-auto flex h-8 w-6 shrink-0 items-center justify-center rounded-md text-faint outline-none transition-colors hover:bg-raised hover:text-fg data-[state=open]:bg-raised data-[state=open]:text-fg"
243+ className="flex h-8 w-5 shrink-0 items-center justify-center rounded-md text-faint outline-none transition-colors hover:bg-raised hover:text-fg data-[state=open]:bg-raised data-[state=open]:text-fg"
240244 >
241245 <ChevronsUpDown size={14} />
242246 </DropdownMenuTrigger>
243− <DropdownMenuContent align="start" className="w-60">
247+ <DropdownMenuContent align="start" className="w-64">
244248 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
245249 {(user.workspaces ?? []).map((membership) => (
246250 <DropdownMenuItem asChild key={membership.slug}>
247− <Link to={`/${membership.slug}`}>
248− <Avatar name={membership.slug} size={16} square />
249− <span className="grow">{membership.slug}</span>
250− {membership.slug === current && <Check className="text-accent" />}
251+ <Link to={`/${membership.slug}`} title={displayName(membership)}>
252+ <Avatar name={membership.slug} image={membership.avatar} size={24} square />
253+ <span className="flex min-w-0 grow flex-col leading-tight">
254+ <span className="truncate">{displayName(membership)}</span>
255+ <span className="truncate font-mono text-[0.6875rem] text-faint">{membership.slug}</span>
256+ </span>
257+ <span className="shrink-0 text-[0.6875rem] text-faint capitalize">{membership.role}</span>
258+ {membership.slug === current ? (
259+ <Check className="shrink-0 text-accent" />
260+ ) : (
261+ <span className="size-4 shrink-0" aria-hidden="true" />
262+ )}
251263 </Link>
252264 </DropdownMenuItem>
253265 ))}
315327 return (
316328 <DropdownMenu>
317329 <DropdownMenuTrigger className="flex h-10 w-full items-center gap-2.5 rounded-md px-2 text-left outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised">
318− <Avatar name={user.username} size={22} />
330+ <Avatar name={user.username} image={user.avatar} size={22} />
319331 <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span>
320332 <Ellipsis size={15} className="shrink-0 text-faint" />
321333 </DropdownMenuTrigger>
589601 return (
590602 <div className="flex h-full flex-col">
591603 {/* The same height and rule as the top bar, so the two read as one line. */}
592− <div className="flex h-16 shrink-0 items-center gap-2 border-b border-line px-3">
593− <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-2 hover:bg-raised">
604+ <div className="flex h-16 shrink-0 items-center gap-1 border-b border-line pr-2 pl-2.5">
605+ <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
594606 <Mark className="size-6" />
595607 </Link>
596608 <span className="shrink-0 text-line-strong" aria-hidden="true">
835847 }
836848 for (const membership of user.workspaces ?? []) {
837849 commands.push(
838− { label: membership.slug, hint: "Workspace", to: `/${membership.slug}`, icon: <Avatar name={membership.slug} size={15} square /> },
850+ { label: displayName(membership), hint: `Workspace · ${membership.slug}`, to: `/${membership.slug}`, icon: <Avatar name={membership.slug} image={membership.avatar} size={15} square /> },
839851 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
840852 { label: "Billing and plans", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
841853 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
+58−0
1+import { AlertDialog as Primitive } from "radix-ui";
2+import type { ComponentProps } from "react";
3+
4+import { cn } from "../../lib/cn";
5+import { DIALOG_PANEL } from "./dialog";
6+
7+// shadcn/ui's alert dialog, for confirming what cannot be undone. It does
8+// not close on a click outside; the person has to choose.
9+
10+export const AlertDialog = Primitive.Root;
11+export const AlertDialogTrigger = Primitive.Trigger;
12+
13+export function AlertDialogContent({ className, ...props }: ComponentProps<typeof Primitive.Content>) {
14+ return (
15+ <Primitive.Portal>
16+ <Primitive.Overlay className="fixed inset-0 z-50 bg-black/60 backdrop-blur-[2px] data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out motion-reduce:animate-none" />
17+ <Primitive.Content className={cn(DIALOG_PANEL, "max-w-md", className)} {...props} />
18+ </Primitive.Portal>
19+ );
20+}
21+
22+export function AlertDialogHeader({ className, ...props }: ComponentProps<"div">) {
23+ return <div className={cn("flex flex-col gap-1.5", className)} {...props} />;
24+}
25+
26+export function AlertDialogFooter({ className, ...props }: ComponentProps<"div">) {
27+ return <div className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />;
28+}
29+
30+export function AlertDialogTitle({ className, ...props }: ComponentProps<typeof Primitive.Title>) {
31+ return <Primitive.Title className={cn("text-base font-semibold tracking-tight", className)} {...props} />;
32+}
33+
34+export function AlertDialogDescription({ className, ...props }: ComponentProps<typeof Primitive.Description>) {
35+ return <Primitive.Description className={cn("text-sm text-muted", className)} {...props} />;
36+}
37+
38+const BUTTON =
39+ "inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2 text-sm font-medium transition-colors disabled:opacity-50";
40+
41+/** The destructive choice. Put a submit button inside with `asChild` to post a form. */
42+export function AlertDialogAction({ className, ...props }: ComponentProps<typeof Primitive.Action>) {
43+ return (
44+ <Primitive.Action
45+ className={cn(BUTTON, "bg-danger text-bg hover:bg-danger/90", className)}
46+ {...props}
47+ />
48+ );
49+}
50+
51+export function AlertDialogCancel({ className, ...props }: ComponentProps<typeof Primitive.Cancel>) {
52+ return (
53+ <Primitive.Cancel
54+ className={cn(BUTTON, "border border-line text-fg/80 hover:border-line-strong hover:bg-raised hover:text-fg", className)}
55+ {...props}
56+ />
57+ );
58+}
+60−0
1+import { Check, Minus } from "lucide-react";
2+import { Checkbox as Primitive } from "radix-ui";
3+import { type ComponentProps, type ReactNode, useId } from "react";
4+
5+import { cn } from "../../lib/cn";
6+
7+// shadcn/ui's checkbox, styled with g1t's tokens.
8+//
9+// Inside a <Form>, a `name` makes Radix render a hidden checkbox beside it,
10+// so it posts `value` (default "on") when checked and nothing when not,
11+// exactly as a native one does. Wrap it and its text in a <label>, or point
12+// a <Label htmlFor> at its `id`.
13+
14+/** A checkbox with its label, and optionally a line under it. */
15+export function CheckboxOption({
16+ label,
17+ description,
18+ className,
19+ labelClassName,
20+ ...props
21+}: ComponentProps<typeof Primitive.Root> & { label: ReactNode; description?: ReactNode; labelClassName?: string }) {
22+ const generated = useId();
23+ const id = props.id ?? generated;
24+ return (
25+ <div className={cn("flex items-start gap-2.5", props.disabled && "opacity-60", className)}>
26+ <Checkbox id={id} className={className?.includes("items-center") ? undefined : "mt-0.5"} aria-describedby={description ? `${id}-d` : undefined} {...props} />
27+ <label htmlFor={id} className={cn("min-w-0 text-sm text-fg", props.disabled ? "cursor-not-allowed" : "cursor-pointer", labelClassName)}>
28+ {label}
29+ {description && (
30+ <span id={`${id}-d`} className="mt-0.5 block text-xs text-faint">
31+ {description}
32+ </span>
33+ )}
34+ </label>
35+ </div>
36+ );
37+}
38+
39+export function Checkbox({ className, ...props }: ComponentProps<typeof Primitive.Root>) {
40+ return (
41+ <Primitive.Root
42+ className={cn(
43+ "peer inline-flex size-4 shrink-0 items-center justify-center rounded-[5px] border border-line-strong bg-bg text-bg outline-none transition-colors",
44+ "hover:border-faint focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-bg focus-visible:outline-none",
45+ "data-[state=checked]:border-accent data-[state=checked]:bg-accent data-[state=indeterminate]:border-accent data-[state=indeterminate]:bg-accent",
46+ "disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-danger/70",
47+ className,
48+ )}
49+ {...props}
50+ >
51+ <Primitive.Indicator className="flex items-center justify-center data-[state=checked]:animate-pop-in motion-reduce:animate-none">
52+ {props.checked === "indeterminate" ? (
53+ <Minus size={12} strokeWidth={3} />
54+ ) : (
55+ <Check size={12} strokeWidth={3} />
56+ )}
57+ </Primitive.Indicator>
58+ </Primitive.Root>
59+ );
60+}
+169−0
1+import { Command as Cmdk } from "cmdk";
2+import { Check, ChevronsUpDown, Search } from "lucide-react";
3+import { type ComponentProps, type ReactNode, useState } from "react";
4+
5+import { cn } from "../../lib/cn";
6+import { Popover, PopoverContent, PopoverTrigger } from "./popover";
7+
8+// shadcn/ui's command menu and combobox, styled with g1t's tokens: a select
9+// with a search box, for lists too long to scroll. A `name` posts the
10+// chosen value from a hidden input, so it works in a plain <Form>.
11+
12+export function Command({ className, ...props }: ComponentProps<typeof Cmdk>) {
13+ return <Cmdk className={cn("flex w-full flex-col overflow-hidden text-sm text-fg", className)} {...props} />;
14+}
15+
16+export function CommandInput({ className, ...props }: ComponentProps<typeof Cmdk.Input>) {
17+ return (
18+ <div className="flex items-center gap-2 border-b border-line px-3" cmdk-input-wrapper="">
19+ <Search size={14} className="shrink-0 text-faint" />
20+ <Cmdk.Input
21+ className={cn("h-10 w-full bg-transparent text-sm outline-none placeholder:text-faint focus-visible:outline-none", className)}
22+ {...props}
23+ />
24+ </div>
25+ );
26+}
27+
28+export function CommandList({ className, ...props }: ComponentProps<typeof Cmdk.List>) {
29+ return <Cmdk.List className={cn("max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto p-1", className)} {...props} />;
30+}
31+
32+export function CommandEmpty({ className, ...props }: ComponentProps<typeof Cmdk.Empty>) {
33+ return <Cmdk.Empty className={cn("py-6 text-center text-sm text-faint", className)} {...props} />;
34+}
35+
36+export function CommandGroup({ className, ...props }: ComponentProps<typeof Cmdk.Group>) {
37+ return (
38+ <Cmdk.Group
39+ className={cn(
40+ "[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pt-2 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:text-[0.6875rem] [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:tracking-wide [&_[cmdk-group-heading]]:text-faint [&_[cmdk-group-heading]]:uppercase",
41+ className,
42+ )}
43+ {...props}
44+ />
45+ );
46+}
47+
48+export function CommandSeparator({ className, ...props }: ComponentProps<typeof Cmdk.Separator>) {
49+ return <Cmdk.Separator className={cn("-mx-1 my-1 h-px bg-line", className)} {...props} />;
50+}
51+
52+export function CommandItem({ className, ...props }: ComponentProps<typeof Cmdk.Item>) {
53+ return (
54+ <Cmdk.Item
55+ className={cn(
56+ "relative flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-fg/90 outline-none select-none",
57+ "data-[selected=true]:bg-line data-[selected=true]:text-fg data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-45",
58+ "[&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted",
59+ className,
60+ )}
61+ {...props}
62+ />
63+ );
64+}
65+
66+export type ComboboxOption = {
67+ value: string;
68+ label: string;
69+ description?: ReactNode;
70+ icon?: ReactNode;
71+ /** More words that should find it. */
72+ keywords?: string[];
73+ disabled?: boolean;
74+};
75+
76+/** A searchable select. Controlled with `value`, or not with `defaultValue`. */
77+export function Combobox({
78+ options,
79+ value: controlled,
80+ defaultValue = "",
81+ onValueChange,
82+ name,
83+ disabled,
84+ placeholder = "Choose…",
85+ searchPlaceholder = "Search…",
86+ emptyText = "Nothing matches.",
87+ id,
88+ className,
89+ "aria-label": ariaLabel,
90+}: {
91+ options: ComboboxOption[];
92+ value?: string;
93+ defaultValue?: string;
94+ onValueChange?: (value: string) => void;
95+ name?: string;
96+ disabled?: boolean;
97+ placeholder?: string;
98+ searchPlaceholder?: string;
99+ emptyText?: string;
100+ id?: string;
101+ className?: string;
102+ "aria-label"?: string;
103+}) {
104+ const [open, setOpen] = useState(false);
105+ const [uncontrolled, setUncontrolled] = useState(defaultValue);
106+ const value = controlled ?? uncontrolled;
107+ const chosen = options.find((option) => option.value === value);
108+ const choose = (next: string) => {
109+ setUncontrolled(next);
110+ onValueChange?.(next);
111+ setOpen(false);
112+ };
113+ return (
114+ <Popover open={open} onOpenChange={setOpen}>
115+ {name && <input type="hidden" name={name} value={value} />}
116+ <PopoverTrigger asChild>
117+ <button
118+ type="button"
119+ id={id}
120+ role="combobox"
121+ aria-expanded={open}
122+ aria-label={ariaLabel}
123+ disabled={disabled}
124+ className={cn(
125+ "group/combobox flex h-9 w-full min-w-0 items-center justify-between gap-2 rounded-md border border-line bg-bg px-3 text-left text-sm text-fg outline-none transition-colors",
126+ "hover:border-line-strong focus-visible:border-accent-dim focus-visible:ring-2 focus-visible:ring-accent/25 focus-visible:outline-none",
127+ "data-[state=open]:border-accent-dim data-[state=open]:ring-2 data-[state=open]:ring-accent/25 disabled:cursor-not-allowed disabled:opacity-50",
128+ className,
129+ )}
130+ >
131+ <span className={cn("flex min-w-0 items-center gap-2 truncate [&_svg]:size-4 [&_svg]:shrink-0", !chosen && "text-faint")}>
132+ {chosen?.icon}
133+ <span className="truncate">{chosen ? chosen.label : placeholder}</span>
134+ </span>
135+ <ChevronsUpDown size={14} className="shrink-0 text-faint" />
136+ </button>
137+ </PopoverTrigger>
138+ <PopoverContent align="start" className="w-(--radix-popover-trigger-width) min-w-60 p-0">
139+ <Command>
140+ <CommandInput placeholder={searchPlaceholder} />
141+ <CommandList>
142+ <CommandEmpty>{emptyText}</CommandEmpty>
143+ {options.map((option) => (
144+ <CommandItem
145+ key={option.value}
146+ value={option.value}
147+ keywords={[option.label, ...(option.keywords ?? [])]}
148+ disabled={option.disabled}
149+ onSelect={() => choose(option.value)}
150+ className="items-start pr-8"
151+ >
152+ <span className="flex min-w-0 flex-col">
153+ <span className="flex min-w-0 items-center gap-2">
154+ {option.icon}
155+ <span className="truncate">{option.label}</span>
156+ </span>
157+ {option.description && (
158+ <span className={cn("mt-0.5 text-xs text-faint", option.icon != null && "pl-6")}>{option.description}</span>
159+ )}
160+ </span>
161+ {option.value === value && <Check className="absolute top-2 right-2 size-3.5! text-accent!" />}
162+ </CommandItem>
163+ ))}
164+ </CommandList>
165+ </Command>
166+ </PopoverContent>
167+ </Popover>
168+ );
169+}
+69−0
1+import { X } from "lucide-react";
2+import { Dialog as Primitive } from "radix-ui";
3+import type { ComponentProps } from "react";
4+
5+import { cn } from "../../lib/cn";
6+
7+// shadcn/ui's dialog, styled with g1t's tokens.
8+
9+export const Dialog = Primitive.Root;
10+export const DialogTrigger = Primitive.Trigger;
11+export const DialogClose = Primitive.Close;
12+export const DialogPortal = Primitive.Portal;
13+
14+export function DialogOverlay({ className, ...props }: ComponentProps<typeof Primitive.Overlay>) {
15+ return (
16+ <Primitive.Overlay
17+ className={cn(
18+ "fixed inset-0 z-50 bg-black/60 backdrop-blur-[2px]",
19+ "data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out motion-reduce:animate-none",
20+ className,
21+ )}
22+ {...props}
23+ />
24+ );
25+}
26+
27+/** The panel, centred; on a phone it fills the width less a gutter. */
28+export const DIALOG_PANEL =
29+ "fixed top-1/2 left-1/2 z-50 grid max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-xl border border-line-strong bg-surface p-6 text-fg shadow-2xl shadow-black/60 outline-none data-[state=open]:animate-dialog-in data-[state=closed]:animate-dialog-out motion-reduce:animate-none";
30+
31+export function DialogContent({
32+ className,
33+ children,
34+ showClose = true,
35+ ...props
36+}: ComponentProps<typeof Primitive.Content> & { showClose?: boolean }) {
37+ return (
38+ <Primitive.Portal>
39+ <DialogOverlay />
40+ <Primitive.Content className={cn(DIALOG_PANEL, className)} {...props}>
41+ {children}
42+ {showClose && (
43+ <Primitive.Close
44+ aria-label="Close"
45+ className="absolute top-4 right-4 rounded-md p-1 text-faint transition-colors hover:bg-raised hover:text-fg"
46+ >
47+ <X size={16} />
48+ </Primitive.Close>
49+ )}
50+ </Primitive.Content>
51+ </Primitive.Portal>
52+ );
53+}
54+
55+export function DialogHeader({ className, ...props }: ComponentProps<"div">) {
56+ return <div className={cn("flex flex-col gap-1.5 pr-6", className)} {...props} />;
57+}
58+
59+export function DialogFooter({ className, ...props }: ComponentProps<"div">) {
60+ return <div className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />;
61+}
62+
63+export function DialogTitle({ className, ...props }: ComponentProps<typeof Primitive.Title>) {
64+ return <Primitive.Title className={cn("text-base font-semibold tracking-tight", className)} {...props} />;
65+}
66+
67+export function DialogDescription({ className, ...props }: ComponentProps<typeof Primitive.Description>) {
68+ return <Primitive.Description className={cn("text-sm text-muted", className)} {...props} />;
69+}
+1−0
2020 sideOffset={sideOffset}
2121 className={cn(
2222 "z-50 min-w-48 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg border border-line-strong bg-raised p-1 text-sm shadow-xl shadow-black/40",
23+ "data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
2324 className,
2425 )}
2526 {...props}
+49−0
1+import type { ComponentProps, ReactNode } from "react";
2+
3+import { cn } from "../../lib/cn";
4+import { Label } from "./label";
5+
6+// shadcn/ui's field: a label, a control, a line of help and an error,
7+// spaced the same way on every form.
8+//
9+// <Field>
10+// <FieldLabel htmlFor="name">Name</FieldLabel>
11+// <Input id="name" name="name" />
12+// <FieldDescription>Shown on the project page.</FieldDescription>
13+// <FieldError>{actionData?.error}</FieldError>
14+// </Field>
15+//
16+// A group of radios or checkboxes goes in a <FieldSet> with a <FieldLegend>.
17+
18+export function Field({ className, ...props }: ComponentProps<"div">) {
19+ return <div role="group" className={cn("flex flex-col gap-1.5", className)} {...props} />;
20+}
21+
22+export function FieldLabel({ className, ...props }: ComponentProps<typeof Label>) {
23+ return <Label className={cn("mb-0", className)} {...props} />;
24+}
25+
26+export function FieldDescription({ className, ...props }: ComponentProps<"p">) {
27+ return <p className={cn("text-xs leading-relaxed text-faint", className)} {...props} />;
28+}
29+
30+export function FieldError({ className, children, ...props }: ComponentProps<"p"> & { children?: ReactNode }) {
31+ if (!children) return null;
32+ return (
33+ <p role="alert" className={cn("text-xs text-danger", className)} {...props}>
34+ {children}
35+ </p>
36+ );
37+}
38+
39+export function FieldSet({ className, ...props }: ComponentProps<"fieldset">) {
40+ return <fieldset className={cn("flex min-w-0 flex-col gap-3", className)} {...props} />;
41+}
42+
43+export function FieldLegend({ className, ...props }: ComponentProps<"legend">) {
44+ return <legend className={cn("mb-3 text-sm font-medium text-muted", className)} {...props} />;
45+}
46+
47+export function FieldGroup({ className, ...props }: ComponentProps<"div">) {
48+ return <div className={cn("flex flex-col gap-6", className)} {...props} />;
49+}
+28−2
153153 /** How g1t's agents are named: as reviewers and assignees, and as commit authors. */
154154 const AGENT_NAMES = new Set(["g1t-agent", "g1t agent", "g1t"]);
155155
156+/** Where an uploaded avatar is served, from the hash it is stored by. */
157+export function avatarUrl(avatar: string): string {
158+ return `/avatars/${avatar}`;
159+}
160+
156161 /**
157− * A letter avatar whose colour is stable for a given name. People are
158− * round; a workspace is `square`.
162+ * An uploaded avatar if there is one, else a letter avatar whose colour is
163+ * stable for a given name. People are round; a workspace is `square`. An
164+ * image that fails to load falls back to the letter.
159165 */
160166 export function Avatar({
161167 name,
162168 size = 20,
163169 square,
170+ image,
164171 }: {
165172 name: string;
166173 size?: number;
167174 square?: boolean;
175+ /** The uploaded avatar's hash, as identity returns it. */
176+ image?: string | null;
168177 }) {
178+ const [failed, setFailed] = useState<string | null>(null);
179+ if (image && failed !== image) {
180+ return (
181+ <img
182+ src={avatarUrl(image)}
183+ alt=""
184+ aria-hidden="true"
185+ width={size}
186+ height={size}
187+ loading="lazy"
188+ decoding="async"
189+ onError={() => setFailed(image)}
190+ className="inline-block shrink-0 bg-raised object-cover"
191+ style={{ width: size, height: size, borderRadius: square ? size * 0.24 : size }}
192+ />
193+ );
194+ }
169195 // g1t's own agents wear the agent colour and mark everywhere they appear.
170196 if (AGENT_NAMES.has(name)) {
171197 return (
+43−0
1+import type { ComponentProps } from "react";
2+
3+import { cn } from "../../lib/cn";
4+import { notACredential } from "./index";
5+
6+// shadcn/ui's input, styled with g1t's tokens. Unlike the older `Input` in
7+// ./index it takes a className, and fields that are not part of signing in
8+// still keep password managers out.
9+
10+export const CONTROL =
11+ "w-full min-w-0 rounded-md border border-line bg-bg px-3 py-2 text-sm text-fg outline-none transition-[border-color,box-shadow] placeholder:text-faint hover:border-line-strong focus-visible:border-accent-dim focus-visible:ring-2 focus-visible:ring-accent/25 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-danger/70";
12+
13+export function Input({ className, ...props }: ComponentProps<"input">) {
14+ return <input {...notACredential(props.autoComplete)} {...props} className={cn(CONTROL, "h-9", className)} />;
15+}
16+
17+/**
18+ * Text either side of an input, joined into one control, as in
19+ * `workspace / name` or `https:// … .g1t.page`. Put addons and an input
20+ * inside; the group draws the border and the focus ring.
21+ */
22+export function InputGroup({ className, ...props }: ComponentProps<"div">) {
23+ return (
24+ <div
25+ className={cn(
26+ "flex h-9 w-full min-w-0 items-stretch overflow-hidden rounded-md border border-line bg-bg text-sm transition-[border-color,box-shadow]",
27+ "hover:border-line-strong has-[:focus-visible]:border-accent-dim has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent/25",
28+ "[&_input]:h-full [&_input]:rounded-none [&_input]:border-0 [&_input]:bg-transparent [&_input]:ring-0 [&_input]:focus-visible:ring-0",
29+ className,
30+ )}
31+ {...props}
32+ />
33+ );
34+}
35+
36+export function InputAddon({ className, ...props }: ComponentProps<"span">) {
37+ return (
38+ <span
39+ className={cn("flex shrink-0 items-center px-2.5 text-faint select-none", className)}
40+ {...props}
41+ />
42+ );
43+}
+19−0
1+import { Label as Primitive } from "radix-ui";
2+import type { ComponentProps } from "react";
3+
4+import { cn } from "../../lib/cn";
5+
6+// shadcn/ui's label, styled with g1t's tokens.
7+
8+export function Label({ className, ...props }: ComponentProps<typeof Primitive.Root>) {
9+ return (
10+ <Primitive.Root
11+ className={cn(
12+ "flex items-center gap-2 text-sm font-medium text-muted select-none",
13+ "peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
14+ className,
15+ )}
16+ {...props}
17+ />
18+ );
19+}
+33−0
1+import { Popover as Primitive } from "radix-ui";
2+import type { ComponentProps } from "react";
3+
4+import { cn } from "../../lib/cn";
5+
6+// shadcn/ui's popover, styled with g1t's tokens.
7+
8+export const Popover = Primitive.Root;
9+export const PopoverTrigger = Primitive.Trigger;
10+export const PopoverAnchor = Primitive.Anchor;
11+export const PopoverClose = Primitive.Close;
12+
13+export function PopoverContent({
14+ className,
15+ align = "center",
16+ sideOffset = 6,
17+ ...props
18+}: ComponentProps<typeof Primitive.Content>) {
19+ return (
20+ <Primitive.Portal>
21+ <Primitive.Content
22+ align={align}
23+ sideOffset={sideOffset}
24+ className={cn(
25+ "z-50 w-72 max-w-[calc(100vw-2rem)] rounded-lg border border-line-strong bg-raised p-4 text-sm text-fg shadow-xl shadow-black/40 outline-none",
26+ "origin-(--radix-popover-content-transform-origin) data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
27+ className,
28+ )}
29+ {...props}
30+ />
31+ </Primitive.Portal>
32+ );
33+}
+138−0
1+import { RadioGroup as Primitive } from "radix-ui";
2+import { type ComponentProps, type ReactNode, useId } from "react";
3+
4+import { cn } from "../../lib/cn";
5+
6+// shadcn/ui's radio group, styled with g1t's tokens, in two shapes:
7+//
8+// - compact: <RadioOption> rows, a dot and a line of text each;
9+// - cards: <RadioCard> tiles, an icon, a title and a description each.
10+//
11+// Inside a <Form>, the group's `name` makes Radix render a hidden radio per
12+// item, so the chosen `value` posts exactly as native radios do. The arrow
13+// keys move between items and choose as they go; disabled items are skipped.
14+
15+export function RadioGroup({ className, ...props }: ComponentProps<typeof Primitive.Root>) {
16+ return <Primitive.Root className={cn("grid gap-2", className)} {...props} />;
17+}
18+
19+/** The bare dot. */
20+export function RadioGroupItem({ className, ...props }: ComponentProps<typeof Primitive.Item>) {
21+ return (
22+ <Primitive.Item
23+ className={cn(
24+ "peer relative inline-flex size-4 shrink-0 items-center justify-center rounded-full border border-line-strong bg-bg outline-none transition-colors",
25+ "hover:border-faint focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-bg focus-visible:outline-none",
26+ "data-[state=checked]:border-accent disabled:cursor-not-allowed disabled:opacity-50",
27+ className,
28+ )}
29+ {...props}
30+ >
31+ <Primitive.Indicator className="size-2 rounded-full bg-accent data-[state=checked]:animate-pop-in motion-reduce:animate-none" />
32+ </Primitive.Item>
33+ );
34+}
35+
36+/** A compact choice: the dot, a label, and optionally a line under it. */
37+export function RadioOption({
38+ value,
39+ label,
40+ description,
41+ icon,
42+ disabled,
43+ className,
44+}: {
45+ value: string;
46+ label: ReactNode;
47+ description?: ReactNode;
48+ icon?: ReactNode;
49+ disabled?: boolean;
50+ className?: string;
51+}) {
52+ const id = useId();
53+ return (
54+ <div className={cn("flex items-start gap-2.5", disabled && "opacity-60", className)}>
55+ <RadioGroupItem value={value} id={id} disabled={disabled} className="mt-0.5" aria-describedby={description ? `${id}-d` : undefined} />
56+ <label htmlFor={id} className={cn("min-w-0 text-sm", disabled ? "cursor-not-allowed" : "cursor-pointer")}>
57+ <span className="flex items-center gap-1.5 text-fg [&_svg]:size-3.5 [&_svg]:text-muted">
58+ {icon}
59+ {label}
60+ </span>
61+ {description && (
62+ <span id={`${id}-d`} className="mt-0.5 block text-xs text-muted">
63+ {description}
64+ </span>
65+ )}
66+ </label>
67+ </div>
68+ );
69+}
70+
71+/**
72+ * A big selectable card. The whole card is the radio: it takes focus, and
73+ * Space or a click chooses it. `badge` replaces the dot (for "Soon").
74+ */
75+export function RadioCard({
76+ value,
77+ title,
78+ description,
79+ icon,
80+ badge,
81+ disabled,
82+ className,
83+ children,
84+}: {
85+ value: string;
86+ title: ReactNode;
87+ description?: ReactNode;
88+ icon?: ReactNode;
89+ badge?: ReactNode;
90+ disabled?: boolean;
91+ className?: string;
92+ children?: ReactNode;
93+}) {
94+ const id = useId();
95+ return (
96+ <Primitive.Item
97+ value={value}
98+ disabled={disabled}
99+ aria-labelledby={`${id}-t`}
100+ aria-describedby={description ? `${id}-d` : undefined}
101+ className={cn(
102+ "group/card relative flex h-full w-full flex-col items-start rounded-xl border border-line bg-surface/40 p-4 text-left outline-none transition-[border-color,background-color,box-shadow] duration-150",
103+ "hover:border-line-strong hover:bg-surface",
104+ "focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none",
105+ "data-[state=checked]:border-accent/70 data-[state=checked]:bg-accent/[0.06] data-[state=checked]:shadow-[0_0_0_1px_color-mix(in_srgb,var(--color-accent)_35%,transparent),0_8px_24px_-12px_color-mix(in_srgb,var(--color-accent)_40%,transparent)]",
106+ "disabled:cursor-not-allowed disabled:border-dashed disabled:bg-transparent disabled:opacity-60 disabled:hover:border-line",
107+ className,
108+ )}
109+ >
110+ <span className="flex w-full items-start justify-between gap-3">
111+ {icon && (
112+ <span className="flex size-8 items-center justify-center rounded-lg bg-raised text-muted ring-1 ring-line transition-colors group-data-[state=checked]/card:bg-accent/10 group-data-[state=checked]/card:text-accent group-data-[state=checked]/card:ring-accent/30 [&_svg]:size-4">
113+ {icon}
114+ </span>
115+ )}
116+ {badge ? (
117+ <span className="ml-auto rounded-full bg-raised px-2 py-0.5 text-[0.6875rem] text-muted ring-1 ring-line">{badge}</span>
118+ ) : (
119+ <span
120+ aria-hidden="true"
121+ className="ml-auto flex size-4 items-center justify-center rounded-full border border-line-strong transition-colors group-data-[state=checked]/card:border-accent"
122+ >
123+ <span className="size-2 scale-0 rounded-full bg-accent transition-transform duration-150 group-data-[state=checked]/card:scale-100" />
124+ </span>
125+ )}
126+ </span>
127+ <span id={`${id}-t`} className={cn("block text-sm font-medium text-fg", icon && "mt-3")}>
128+ {title}
129+ </span>
130+ {description && (
131+ <span id={`${id}-d`} className="mt-1 block text-xs leading-relaxed text-muted">
132+ {description}
133+ </span>
134+ )}
135+ {children}
136+ </Primitive.Item>
137+ );
138+}
+159−0
1+import { Check, ChevronDown, ChevronUp } from "lucide-react";
2+import { Select as Primitive } from "radix-ui";
3+import type { ComponentProps, ReactNode } from "react";
4+
5+import { cn } from "../../lib/cn";
6+
7+// shadcn/ui's select, styled with g1t's tokens.
8+//
9+// Inside a <Form>, give <Select> a `name` and Radix renders a hidden native
10+// <select> beside the trigger, so the value posts like any other field.
11+// An item's value can never be "": use a word such as "none" and map it.
12+
13+export const Select = Primitive.Root;
14+export const SelectGroup = Primitive.Group;
15+export const SelectValue = Primitive.Value;
16+
17+const TRIGGER_SIZES = { sm: "h-8 px-2.5 text-[0.8125rem]", default: "h-9", lg: "h-11" };
18+
19+/** The control people see: the chosen value, or the placeholder, and a chevron. */
20+export function SelectTrigger({
21+ className,
22+ size = "default",
23+ children,
24+ ...props
25+}: ComponentProps<typeof Primitive.Trigger> & { size?: "sm" | "default" | "lg" }) {
26+ return (
27+ <Primitive.Trigger
28+ data-size={size}
29+ className={cn(
30+ "group/select flex w-full min-w-0 items-center justify-between gap-2 rounded-md border border-line bg-bg px-3 text-left text-sm whitespace-nowrap text-fg outline-none transition-colors",
31+ TRIGGER_SIZES[size],
32+ "hover:border-line-strong focus-visible:border-accent-dim focus-visible:ring-2 focus-visible:ring-accent/25 focus-visible:outline-none",
33+ "data-[state=open]:border-accent-dim data-[state=open]:ring-2 data-[state=open]:ring-accent/25",
34+ "data-placeholder:text-faint aria-invalid:border-danger/70",
35+ "disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-line",
36+ "[&>span]:flex [&>span]:min-w-0 [&>span]:items-center [&>span]:gap-2 [&>span]:truncate",
37+ "[&_svg]:shrink-0",
38+ className,
39+ )}
40+ {...props}
41+ >
42+ {children}
43+ <Primitive.Icon asChild>
44+ <ChevronDown
45+ size={15}
46+ className="text-faint transition-transform duration-200 group-data-[state=open]/select:rotate-180"
47+ />
48+ </Primitive.Icon>
49+ </Primitive.Trigger>
50+ );
51+}
52+
53+export function SelectContent({
54+ className,
55+ children,
56+ position = "popper",
57+ sideOffset = 6,
58+ ...props
59+}: ComponentProps<typeof Primitive.Content>) {
60+ return (
61+ <Primitive.Portal>
62+ <Primitive.Content
63+ position={position}
64+ sideOffset={position === "popper" ? sideOffset : undefined}
65+ className={cn(
66+ "relative z-50 max-h-(--radix-select-content-available-height) min-w-36 overflow-hidden rounded-lg border border-line-strong bg-raised text-sm text-fg shadow-xl shadow-black/40",
67+ "origin-(--radix-select-content-transform-origin) data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
68+ position === "popper" && "min-w-(--radix-select-trigger-width) max-w-[calc(100vw-2rem)]",
69+ className,
70+ )}
71+ {...props}
72+ >
73+ <SelectScrollUpButton />
74+ <Primitive.Viewport
75+ className={cn("p-1", position === "popper" && "max-h-80 scroll-my-1")}
76+ >
77+ {children}
78+ </Primitive.Viewport>
79+ <SelectScrollDownButton />
80+ </Primitive.Content>
81+ </Primitive.Portal>
82+ );
83+}
84+
85+export function SelectLabel({ className, ...props }: ComponentProps<typeof Primitive.Label>) {
86+ return (
87+ <Primitive.Label
88+ className={cn("px-2 pt-2 pb-1 text-[0.6875rem] font-medium tracking-wide text-faint uppercase", className)}
89+ {...props}
90+ />
91+ );
92+}
93+
94+/**
95+ * One option. `icon` (an icon or an avatar) and the label show in the
96+ * trigger once chosen; `description` shows only in the list.
97+ */
98+export function SelectItem({
99+ className,
100+ children,
101+ icon,
102+ description,
103+ ...props
104+}: ComponentProps<typeof Primitive.Item> & { icon?: ReactNode; description?: ReactNode }) {
105+ return (
106+ <Primitive.Item
107+ className={cn(
108+ "relative flex w-full cursor-default items-start gap-2 rounded-md py-1.5 pr-8 pl-2 text-fg/90 outline-none select-none",
109+ "data-highlighted:bg-line data-highlighted:text-fg data-disabled:pointer-events-none data-disabled:opacity-45",
110+ "data-[state=checked]:text-fg",
111+ className,
112+ )}
113+ {...props}
114+ >
115+ <span className="flex min-w-0 flex-col">
116+ <Primitive.ItemText>
117+ <span className="flex min-w-0 items-center gap-2 [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted">
118+ {icon}
119+ <span className="truncate">{children}</span>
120+ </span>
121+ </Primitive.ItemText>
122+ {description && (
123+ <span className={cn("mt-0.5 text-xs leading-snug text-faint", icon != null && "pl-6")}>{description}</span>
124+ )}
125+ </span>
126+ <span className="absolute top-2 right-2 flex size-4 items-center justify-center">
127+ <Primitive.ItemIndicator>
128+ <Check size={14} className="text-accent" />
129+ </Primitive.ItemIndicator>
130+ </span>
131+ </Primitive.Item>
132+ );
133+}
134+
135+export function SelectSeparator({ className, ...props }: ComponentProps<typeof Primitive.Separator>) {
136+ return <Primitive.Separator className={cn("-mx-1 my-1 h-px bg-line", className)} {...props} />;
137+}
138+
139+export function SelectScrollUpButton({ className, ...props }: ComponentProps<typeof Primitive.ScrollUpButton>) {
140+ return (
141+ <Primitive.ScrollUpButton
142+ className={cn("flex cursor-default items-center justify-center py-1 text-faint", className)}
143+ {...props}
144+ >
145+ <ChevronUp size={14} />
146+ </Primitive.ScrollUpButton>
147+ );
148+}
149+
150+export function SelectScrollDownButton({ className, ...props }: ComponentProps<typeof Primitive.ScrollDownButton>) {
151+ return (
152+ <Primitive.ScrollDownButton
153+ className={cn("flex cursor-default items-center justify-center py-1 text-faint", className)}
154+ {...props}
155+ >
156+ <ChevronDown size={14} />
157+ </Primitive.ScrollDownButton>
158+ );
159+}
+66−0
1+import { Switch as Primitive } from "radix-ui";
2+import { type ComponentProps, type ReactNode, useId } from "react";
3+
4+import { cn } from "../../lib/cn";
5+
6+// shadcn/ui's switch, styled with g1t's tokens. Like a checkbox, inside a
7+// <Form> a `name` posts `value` (default "on") when on and nothing when off.
8+
9+export function Switch({
10+ className,
11+ size = "default",
12+ ...props
13+}: ComponentProps<typeof Primitive.Root> & { size?: "sm" | "default" }) {
14+ return (
15+ <Primitive.Root
16+ data-size={size}
17+ className={cn(
18+ "peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent p-px outline-none transition-colors",
19+ "data-[size=default]:h-5 data-[size=default]:w-9 data-[size=sm]:h-4 data-[size=sm]:w-7",
20+ "bg-line-strong data-[state=checked]:bg-accent",
21+ "focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-bg focus-visible:outline-none",
22+ "disabled:cursor-not-allowed disabled:opacity-50",
23+ className,
24+ )}
25+ {...props}
26+ >
27+ <Primitive.Thumb
28+ className={cn(
29+ "pointer-events-none block rounded-full bg-fg shadow-sm ring-0 transition-transform duration-200",
30+ "group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3",
31+ "data-[state=unchecked]:translate-x-0 group-data-[size=default]/switch:data-[state=checked]:translate-x-4 group-data-[size=sm]/switch:data-[state=checked]:translate-x-3",
32+ )}
33+ />
34+ </Primitive.Root>
35+ );
36+}
37+
38+/**
39+ * A setting that is on or off, as a card: a title, what it means, and the
40+ * switch. The whole card toggles it.
41+ */
42+export function SwitchCard({
43+ title,
44+ children,
45+ className,
46+ ...props
47+}: ComponentProps<typeof Primitive.Root> & { title: ReactNode; children?: ReactNode }) {
48+ const generated = useId();
49+ const id = props.id ?? generated;
50+ return (
51+ <label
52+ htmlFor={id}
53+ className={cn(
54+ "flex cursor-pointer items-start justify-between gap-4 rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong",
55+ "has-[[data-state=checked]]:border-accent/40 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60",
56+ className,
57+ )}
58+ >
59+ <span className="min-w-0">
60+ <span className="block text-sm font-medium text-fg">{title}</span>
61+ {children && <span className="mt-1 block text-sm text-muted">{children}</span>}
62+ </span>
63+ <Switch id={id} className="mt-0.5" {...props} />
64+ </label>
65+ );
66+}
+17−0
1+import type { ComponentProps } from "react";
2+
3+import { cn } from "../../lib/cn";
4+import { notACredential } from "./index";
5+import { CONTROL } from "./input";
6+
7+// shadcn/ui's textarea, styled with g1t's tokens.
8+
9+export function Textarea({ className, ...props }: ComponentProps<"textarea">) {
10+ return (
11+ <textarea
12+ {...notACredential(props.autoComplete)}
13+ {...props}
14+ className={cn(CONTROL, "min-h-20 resize-y leading-relaxed", className)}
15+ />
16+ );
17+}
+45−0
1+import { Tooltip as Primitive } from "radix-ui";
2+import type { ComponentProps } from "react";
3+
4+import { cn } from "../../lib/cn";
5+
6+// shadcn/ui's tooltip, styled with g1t's tokens. Each tooltip carries its
7+// own provider, so one can be dropped in anywhere without setup.
8+
9+export function TooltipProvider({ delayDuration = 200, ...props }: ComponentProps<typeof Primitive.Provider>) {
10+ return <Primitive.Provider delayDuration={delayDuration} {...props} />;
11+}
12+
13+export function Tooltip(props: ComponentProps<typeof Primitive.Root>) {
14+ return (
15+ <TooltipProvider>
16+ <Primitive.Root {...props} />
17+ </TooltipProvider>
18+ );
19+}
20+
21+export const TooltipTrigger = Primitive.Trigger;
22+
23+export function TooltipContent({
24+ className,
25+ sideOffset = 6,
26+ children,
27+ ...props
28+}: ComponentProps<typeof Primitive.Content>) {
29+ return (
30+ <Primitive.Portal>
31+ <Primitive.Content
32+ sideOffset={sideOffset}
33+ className={cn(
34+ "z-50 max-w-xs rounded-md border border-line-strong bg-raised px-2.5 py-1.5 text-xs text-fg shadow-lg shadow-black/40",
35+ "origin-(--radix-tooltip-content-transform-origin) animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
36+ className,
37+ )}
38+ {...props}
39+ >
40+ {children}
41+ <Primitive.Arrow className="fill-raised" width={10} height={5} />
42+ </Primitive.Content>
43+ </Primitive.Portal>
44+ );
45+}
+20−14
1010
1111 import type { WebhooksAction, WebhooksData } from "../lib/webhooks.server";
1212 import { Button, CopyLine, EmptyState, ErrorText, Field, Input, TimeAgo } from "./ui";
13+import { CheckboxOption } from "./ui/checkbox";
14+import { RadioGroup, RadioOption } from "./ui/radio-group";
1315
1416 /** The events, in groups people recognise. */
1517 const GROUPS: { title: string; events: string[] }[] = [
183185 </Field>
184186 <fieldset>
185187 <legend className="mb-1.5 text-sm font-medium text-muted">Which events</legend>
186− <div className="flex flex-wrap gap-4 text-sm">
187− <label className="flex items-center gap-2">
188− <input type="radio" name="which" value="all" checked={which === "all"} onChange={() => setWhich("all")} />
189− Everything, including events added later
190− </label>
191− <label className="flex items-center gap-2">
192− <input type="radio" name="which" value="some" checked={which === "some"} onChange={() => setWhich("some")} />
193− Let me choose
194− </label>
195− </div>
188+ <RadioGroup
189+ name="which"
190+ value={which}
191+ onValueChange={(value) => setWhich(value as typeof which)}
192+ aria-label="Which events"
193+ className="flex flex-wrap gap-x-6 gap-y-2"
194+ >
195+ <RadioOption value="all" label="Everything, including events added later" />
196+ <RadioOption value="some" label="Let me choose" />
197+ </RadioGroup>
196198 {which === "some" && (
197199 <div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
198200 {GROUPS.map((group) => (
202204 {group.events
203205 .filter((event) => (EVENT_TYPES as readonly string[]).includes(event))
204206 .map((event) => (
205− <label key={event} className="flex items-center gap-2 font-mono text-[0.8125rem]">
206− <input type="checkbox" name="event" value={event} />
207− {event}
208− </label>
207+ <CheckboxOption
208+ key={event}
209+ name="event"
210+ value={event}
211+ label={event}
212+ className="items-center"
213+ labelClassName="font-mono text-[0.8125rem]"
214+ />
209215 ))}
210216 </div>
211217 </div>
+15−11
1616 import { repoAt } from "../lib/markdown-plugins";
1717 import { Markdown } from "./markdown";
1818 import { Avatar, Button, Textarea, TimeAgo } from "./ui";
19+import { CheckboxOption } from "./ui/checkbox";
1920
2021 /** Hues for the labels every repository starts with. */
2122 const LABEL_HUES: Record<string, number> = {
426427 {people.length > 0 && (
427428 <div className="space-y-1.5">
428429 {people.map((person) => (
429− <label key={person} className="flex cursor-pointer items-center gap-2 text-sm">
430− <input
431− type="checkbox"
432− name={name}
433− value={person}
434− defaultChecked={chosen.includes(person)}
435− className="accent-accent"
436− />
437− <Avatar name={person} size={18} />
438− <span className="font-mono text-xs">{person}</span>
439− </label>
430+ <CheckboxOption
431+ key={person}
432+ name={name}
433+ value={person}
434+ defaultChecked={chosen.includes(person)}
435+ className="items-center"
436+ labelClassName="flex items-center gap-2"
437+ label={
438+ <>
439+ <Avatar name={person} size={18} />
440+ <span className="font-mono text-xs">{person}</span>
441+ </>
442+ }
443+ />
440444 ))}
441445 </div>
442446 )}
+49−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { MAX_AVATAR_BYTES, readAvatarUpload, sniffImage, toBase64 } from "./avatar-upload.ts";
5+
6+const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
7+const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 16]);
8+const text = (value: string) => new TextEncoder().encode(value);
9+
10+function form(bytes: Uint8Array, name = "icon.png", type = "image/png"): FormData {
11+ const data = new FormData();
12+ data.set("avatar", new File([bytes], name, { type }));
13+ return data;
14+}
15+
16+test("an image is known by its bytes", () => {
17+ assert.equal(sniffImage(PNG), "image/png");
18+ assert.equal(sniffImage(JPEG), "image/jpeg");
19+ assert.equal(sniffImage(text("GIF89a\x01\x00")), "image/gif");
20+ assert.equal(sniffImage(text("RIFF\x24\x00\x00\x00WEBPVP8 ")), "image/webp");
21+});
22+
23+test("SVG and anything else is refused, whatever it is called", async () => {
24+ assert.equal(sniffImage(text('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>')), null);
25+ assert.equal(sniffImage(text("RIFF\x00\x00\x00\x00WAVEfmt ")), null);
26+ const disguised = await readAvatarUpload(form(text("<svg onload=alert(1)/>"), "icon.png", "image/png"));
27+ assert.ok("error" in disguised);
28+});
29+
30+test("more than a megabyte is refused", async () => {
31+ const big = new Uint8Array(MAX_AVATAR_BYTES + 1);
32+ big.set(PNG);
33+ const result = await readAvatarUpload(form(big));
34+ assert.deepEqual(result, { error: "Use an image of at most 1 MB." });
35+ const exact = new Uint8Array(MAX_AVATAR_BYTES);
36+ exact.set(PNG);
37+ const fits = await readAvatarUpload(form(exact));
38+ assert.ok("image" in fits);
39+});
40+
41+test("an image is sent as base64", async () => {
42+ const result = await readAvatarUpload(form(PNG));
43+ assert.deepEqual(result, { image: toBase64(PNG) });
44+ assert.equal(Buffer.from(toBase64(PNG), "base64").compare(Buffer.from(PNG)), 0);
45+});
46+
47+test("a missing file is asked for", async () => {
48+ assert.deepEqual(await readAvatarUpload(new FormData()), { error: "Choose an image to upload." });
49+});
+50−0
1+/**
2+ * An avatar as it arrives from a form: read, checked and turned into the
3+ * base64 the identity service takes. Identity checks it again, by its
4+ * bytes, and is the one that decides; this only turns away what is
5+ * plainly wrong before sending a megabyte across.
6+ */
7+
8+/** As `MAX_AVATAR_BYTES` in the contracts: kept here so tests run without them. */
9+export const MAX_AVATAR_BYTES = 1024 * 1024;
10+
11+/** What an `<input type="file">` offers to pick. SVG is left out on purpose. */
12+export const AVATAR_ACCEPT = "image/png,image/jpeg,image/webp,image/gif";
13+
14+/** The type an image's first bytes show it to be, or null. As identity's `sniff`. */
15+export function sniffImage(bytes: Uint8Array): string | null {
16+ const starts = (...prefix: number[]) => prefix.every((byte, i) => bytes[i] === byte);
17+ const ascii = (at: number, text: string) => [...text].every((char, i) => bytes[at + i] === char.charCodeAt(0));
18+ if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
19+ if (starts(0xff, 0xd8, 0xff)) return "image/jpeg";
20+ if (ascii(0, "GIF87a") || ascii(0, "GIF89a")) return "image/gif";
21+ if (bytes.length >= 12 && ascii(0, "RIFF") && ascii(8, "WEBP")) return "image/webp";
22+ return null;
23+}
24+
25+/** Bytes as base64, in chunks so a large file does not overflow the stack. */
26+export function toBase64(bytes: Uint8Array): string {
27+ let binary = "";
28+ for (let i = 0; i < bytes.length; i += 0x8000) {
29+ binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
30+ }
31+ return btoa(binary);
32+}
33+
34+export type AvatarUpload = { image: string } | { error: string };
35+
36+/** Reads the file in `field` of a submitted form. */
37+export async function readAvatarUpload(form: FormData, field = "avatar"): Promise<AvatarUpload> {
38+ const file = form.get(field);
39+ if (!file || typeof file === "string" || file.size === 0) {
40+ return { error: "Choose an image to upload." };
41+ }
42+ if (file.size > MAX_AVATAR_BYTES) {
43+ return { error: "Use an image of at most 1 MB." };
44+ }
45+ const bytes = new Uint8Array(await file.arrayBuffer());
46+ if (!sniffImage(bytes)) {
47+ return { error: "Use a PNG, JPEG, WebP or GIF image." };
48+ }
49+ return { image: toBase64(bytes) };
50+}
+7−2
4040 project?: { name?: string; description?: string | null } | null;
4141 open?: { issues?: number; pulls?: number };
4242 };
43−type WorkspaceData = { workspace?: { name?: string; description?: string | null } };
43+type WorkspaceData = { workspace?: { name?: string; description?: string | null; avatar?: string | null } };
4444
4545 function loaded<T>(args: PageArgs, id: string): T | undefined {
4646 return args.matches.find((match) => match?.id === id)?.loaderData as T | undefined;
101101 (project
102102 ? [project.project?.name, project.project?.description ?? project.repo?.description, project.open]
103103 : workspace
104− ? [workspace.workspace?.name, workspace.workspace?.description]
104+ ? [
105+ workspace.workspace?.name,
106+ workspace.workspace?.description,
107+ // Only when there is one, so cards without an icon keep their address.
108+ ...(workspace.workspace?.avatar ? [workspace.workspace.avatar] : []),
109+ ]
105110 : undefined);
106111 const pathname = args.location.pathname.replace(/\/+$/, "") || "/";
107112 // The title a preview shows needs no "· g1t": the site name is beside it.
+3−3
191191 aria-label="Account menu"
192192 className="flex items-center gap-1.5 rounded-md p-1 outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised"
193193 >
194− <Avatar name={user.username} size={24} />
194+ <Avatar name={user.username} image={user.avatar} size={24} />
195195 <ChevronDown size={14} className="text-faint" />
196196 </DropdownMenuTrigger>
197197 <DropdownMenuContent align="end" className="w-56">
205205 {(user.workspaces ?? []).map((membership) => (
206206 <DropdownMenuItem asChild key={membership.slug}>
207207 <Link to={`/${membership.slug}`}>
208− <Avatar name={membership.slug} size={16} square />
209− {membership.slug}
208+ <Avatar name={membership.slug} image={membership.avatar} size={16} square />
209+ <span className="min-w-0 truncate">{membership.name || membership.slug}</span>
210210 </Link>
211211 </DropdownMenuItem>
212212 ))}
+24−6
153153 * what is moving in it, and the way to its people and settings. The rest of
154154 * the page stays the viewer's own, across every workspace.
155155 */
156−function WorkspaceCard({ slug, role, repos, active }: { slug: string; role: string; repos: Repo[]; active: Active[] }) {
156+function WorkspaceCard({
157+ slug,
158+ name,
159+ avatar,
160+ role,
161+ repos,
162+ active,
163+}: {
164+ slug: string;
165+ name: string;
166+ avatar?: string | null;
167+ role: string;
168+ repos: Repo[];
169+ active: Active[];
170+}) {
157171 const mine = active.filter((item) => item.repo.namespace.toLowerCase() === slug);
158172 const stats = [
159173 { value: repos.filter((repo) => repo.namespace.toLowerCase() === slug).length, label: "repositories" },
163177 return (
164178 <div className="rounded-xl border border-line bg-surface p-5">
165179 <div className="flex items-center gap-3">
166− <Avatar name={slug} size={32} square />
180+ <Avatar name={slug} image={avatar} size={32} square />
167181 <div className="min-w-0">
168− <Link to={`/${slug}`} className="block truncate font-medium hover:underline">
169− {slug}
182+ <Link to={`/${slug}`} title={name} className="block truncate font-medium hover:underline">
183+ {name}
170184 </Link>
171− <p className="text-xs text-muted">Workspace · {role}</p>
185+ <p className="truncate text-xs text-muted">
186+ <span className="font-mono">g1t.sh/{slug}</span> · <span className="capitalize">{role}</span>
187+ </p>
172188 </div>
173189 </div>
174190 <dl className="mt-4 grid grid-cols-3 gap-2">
339355 <div className="min-w-0 space-y-10">
340356 <section>
341357 <div className="flex items-center gap-3">
342− <Avatar name={viewer.username} size={36} />
358+ <Avatar name={viewer.username} image={viewer.avatar} size={36} />
343359 <div>
344360 <h1 className="text-xl font-semibold tracking-tight">
345361 Mission control
429445 {shell?.workspace && (
430446 <WorkspaceCard
431447 slug={shell.workspace.slug.toLowerCase()}
448+ name={shell.workspace.name || shell.workspace.slug}
449+ avatar={shell.workspace.avatar}
432450 role={shell.workspace.role}
433451 repos={repos}
434452 active={active}
+116−80
1−import { Box, Download, GitBranch, Sparkles } from "lucide-react";
1+import { Box, Download, GitBranch, Globe, Lock, Sparkles } from "lucide-react";
22 import { useState } from "react";
33 import { Form, redirect, useNavigation } from "react-router";
44
55 import type { Route } from "./+types/new";
66 import { page } from "../lib/meta";
7−import { Button, ErrorText, Field, Input } from "../components/ui";
7+import { Avatar, Button, ErrorText } from "../components/ui";
8+import { Field, FieldDescription, FieldLabel, FieldLegend, FieldSet } from "../components/ui/field";
9+import { Input, InputAddon, InputGroup } from "../components/ui/input";
10+import { RadioCard, RadioGroup } from "../components/ui/radio-group";
11+import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "../components/ui/select";
812 import { repos } from "../lib/services.server";
913 import { assertSameOrigin, requireUser } from "../lib/session.server";
1014
1418
1519 export function loader({ request, context }: Route.LoaderArgs) {
1620 const user = requireUser(context, request);
17− const workspaces = (user.workspaces ?? []).map((membership) => membership.slug);
21+ const workspaces = (user.workspaces ?? []).map((membership) => ({
22+ slug: membership.slug,
23+ role: membership.role,
24+ name: membership.name ?? membership.slug,
25+ avatar: membership.avatar ?? null,
26+ }));
1827 // Projects live in a workspace, so there has to be one first.
1928 if (workspaces.length === 0) throw redirect("/workspaces/new");
2029 const asked = new URL(request.url).searchParams.get("workspace");
2130 return {
2231 workspaces,
23− selected: asked && workspaces.includes(asked) ? asked : workspaces[0],
32+ selected: asked && workspaces.some((workspace) => workspace.slug === asked) ? asked : workspaces[0].slug,
2433 };
2534 }
2635
4857 id: "empty",
4958 title: "Start empty",
5059 text: "A new repository on g1t. Push to it, or hand an agent the first issue.",
51− icon: <Sparkles size={16} />,
60+ icon: <Sparkles />,
5261 },
5362 {
5463 id: "import",
5564 title: "Import code",
5665 text: "Copy a public repository from GitHub or any git host into a new one on g1t.",
57− icon: <Download size={16} />,
66+ icon: <Download />,
5867 },
5968 {
6069 id: "mirror",
6170 title: "Mirror GitHub, GitLab or Bitbucket",
6271 text: "Keep the code where it is and get g1t's deployments and agents on it.",
63− icon: <GitBranch size={16} />,
72+ icon: <GitBranch />,
6473 soon: true,
6574 },
6675 ];
6776
77+const ROLE_LABELS: Record<string, string> = { owner: "Owner", admin: "Admin", member: "Member" };
78+
6879 export default function NewProject({ loaderData, actionData }: Route.ComponentProps) {
6980 const [source, setSource] = useState<Source>("empty");
81+ const [workspace, setWorkspace] = useState(loaderData.selected);
82+ const [name, setName] = useState("");
7083 const busy = useNavigation().state === "submitting";
7184 return (
7285 <main className="mx-auto max-w-2xl px-4 py-12">
8093 </p>
8194
8295 <Form method="post" className="mt-8 space-y-8">
83− <fieldset>
84− <legend className="mb-3 text-sm font-medium text-muted">Where its code comes from</legend>
85− <div className="grid gap-3 sm:grid-cols-3">
86− {SOURCES.map((option) => {
87− const chosen = source === option.id;
88− return (
89− <label
90− key={option.id}
91− className={`relative rounded-xl border p-4 transition-colors ${
92− option.soon
93− ? "cursor-not-allowed border-dashed border-line opacity-60"
94− : chosen
95− ? "cursor-pointer border-accent bg-accent/5"
96− : "cursor-pointer border-line hover:border-line-strong"
97− }`}
98− >
99− <input
100− type="radio"
101− name="source"
102− value={option.id}
103− checked={chosen}
104− disabled={option.soon}
105− onChange={() => !option.soon && setSource(option.id as Source)}
106− className="sr-only"
107− />
108− <span className={chosen ? "text-accent" : "text-muted"}>{option.icon}</span>
109− <span className="mt-2 block text-sm font-medium">{option.title}</span>
110− <span className="mt-1 block text-xs text-muted">{option.text}</span>
111− {option.soon && (
112− <span className="absolute top-3 right-3 rounded-full bg-raised px-2 py-0.5 text-[0.6875rem] text-muted ring-1 ring-line">
113− Soon
114− </span>
115− )}
116− </label>
117− );
118− })}
119− </div>
120− </fieldset>
96+ <FieldSet>
97+ <FieldLegend>Where its code comes from</FieldLegend>
98+ <RadioGroup
99+ name="source"
100+ value={source}
101+ onValueChange={(value) => setSource(value as Source)}
102+ aria-label="Where its code comes from"
103+ className="gap-3 sm:grid-cols-3"
104+ >
105+ {SOURCES.map((option) => (
106+ <RadioCard
107+ key={option.id}
108+ value={option.id}
109+ title={option.title}
110+ description={option.text}
111+ icon={option.icon}
112+ disabled={option.soon}
113+ badge={option.soon ? "Soon" : undefined}
114+ />
115+ ))}
116+ </RadioGroup>
117+ </FieldSet>
121118
122119 {source === "import" && (
123− <Field
124− label="Repository to import"
125− hint="The address of a public repository. Its default branch is copied, up to 40 MB."
126− >
127− <Input name="importUrl" type="url" required placeholder="https://github.com/owner/repo" />
120+ <Field className="animate-fade-in">
121+ <FieldLabel htmlFor="importUrl">Repository to import</FieldLabel>
122+ <Input id="importUrl" name="importUrl" type="url" required placeholder="https://github.com/owner/repo" />
123+ <FieldDescription>The address of a public repository. Its default branch is copied, up to 40 MB.</FieldDescription>
128124 </Field>
129125 )}
130126
131− <Field label="Name" hint="The project and its repository share it: g1t.sh/<workspace>/<name>.">
132− <div className="flex items-center gap-2 font-mono text-sm">
133− <select
134− name="workspace"
135− defaultValue={loaderData.selected}
136− aria-label="Workspace"
137− className="rounded-md border border-line bg-bg px-2 py-2 text-sm"
138− >
139− {loaderData.workspaces.map((slug) => (
140− <option key={slug} value={slug}>
141− {slug}
142− </option>
143− ))}
144− </select>
145− <span className="text-muted">/</span>
146− <Input name="name" required autoFocus maxLength={100} placeholder="my-app" />
147− </div>
127+ <Field>
128+ <FieldLabel htmlFor="name">Name</FieldLabel>
129+ <InputGroup className="h-10">
130+ <Select name="workspace" value={workspace} onValueChange={setWorkspace}>
131+ <SelectTrigger
132+ aria-label="Workspace"
133+ className="h-full w-auto max-w-[55%] shrink-0 rounded-none border-0 bg-surface px-2.5 font-medium hover:bg-raised focus-visible:ring-0 data-[state=open]:bg-raised data-[state=open]:ring-0 sm:max-w-[45%]"
134+ >
135+ <SelectValue />
136+ </SelectTrigger>
137+ <SelectContent align="start" className="min-w-64">
138+ <SelectGroup>
139+ <SelectLabel>Workspaces</SelectLabel>
140+ {loaderData.workspaces.map((option) => (
141+ <SelectItem
142+ key={option.slug}
143+ value={option.slug}
144+ icon={<Avatar name={option.name} image={option.avatar} size={18} square />}
145+ description={`g1t.sh/${option.slug} · ${ROLE_LABELS[option.role] ?? option.role}`}
146+ >
147+ {option.name}
148+ </SelectItem>
149+ ))}
150+ </SelectGroup>
151+ </SelectContent>
152+ </Select>
153+ <InputAddon className="border-l border-line px-2 font-mono text-base text-faint">/</InputAddon>
154+ <Input
155+ id="name"
156+ name="name"
157+ required
158+ autoFocus
159+ maxLength={100}
160+ placeholder="my-app"
161+ value={name}
162+ onChange={(event) => setName(event.target.value)}
163+ className="pl-1 font-mono"
164+ />
165+ </InputGroup>
166+ <FieldDescription>
167+ The project and its repository share it:{" "}
168+ <span className="font-mono text-muted">
169+ g1t.sh/{workspace}/<span className={name ? "text-fg" : undefined}>{name || "name"}</span>
170+ </span>
171+ </FieldDescription>
148172 </Field>
149− <Field label="Description (optional)">
150− <Input name="description" maxLength={200} placeholder="What it is, in a line" />
173+
174+ <Field>
175+ <FieldLabel htmlFor="description">
176+ Description <span className="font-normal text-faint">(optional)</span>
177+ </FieldLabel>
178+ <Input id="description" name="description" maxLength={200} placeholder="What it is, in a line" />
151179 </Field>
152− <fieldset className="space-y-2 text-sm">
153− <legend className="mb-2 font-medium text-muted">Who can see it</legend>
154− <label className="flex items-center gap-2">
155− <input type="radio" name="visibility" value="public" defaultChecked className="accent-accent" />
156− Public: anyone can see its code and open issues
157− </label>
158− <label className="flex items-center gap-2">
159− <input type="radio" name="visibility" value="private" className="accent-accent" />
160− Private: only members of the workspace
161− </label>
162− </fieldset>
180+
181+ <FieldSet>
182+ <FieldLegend>Who can see it</FieldLegend>
183+ <RadioGroup name="visibility" defaultValue="public" aria-label="Who can see it" className="gap-3 sm:grid-cols-2">
184+ <RadioCard
185+ value="public"
186+ icon={<Globe />}
187+ title="Public"
188+ description="Anyone can see its code and open issues."
189+ />
190+ <RadioCard
191+ value="private"
192+ icon={<Lock />}
193+ title="Private"
194+ description="Only members of the workspace."
195+ />
196+ </RadioGroup>
197+ </FieldSet>
198+
163199 <ErrorText>{actionData?.error}</ErrorText>
164200 <Button type="submit" variant="accent" disabled={busy}>
165201 Create project
+22−11
99 import { page } from "../../lib/meta";
1010 import { Notes, StatusIcon, duration, shortRef } from "../../components/actions";
1111 import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
12+import { CheckboxOption } from "../../components/ui/checkbox";
13+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
1214 import { actions } from "../../lib/services.server";
1315 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
1416
124126 const control = "w-full rounded-md border border-line bg-bg px-2.5 py-1.5 text-sm outline-none focus:border-accent-dim";
125127 if (spec.type === "boolean") {
126128 return (
127− <label className="flex items-center gap-2 text-sm">
128− <input type="checkbox" name={`input.${name}`} defaultChecked={spec.default === true || spec.default === "true"} />
129− {spec.description || name}
130− </label>
129+ <CheckboxOption
130+ name={`input.${name}`}
131+ defaultChecked={spec.default === true || spec.default === "true"}
132+ label={spec.description || name}
133+ />
131134 );
132135 }
133136 if (spec.type === "choice" && spec.options) {
134137 return (
135− <label className="block">
138+ <div>
136139 {label}
137− <select name={`input.${name}`} defaultValue={String(spec.default ?? spec.options[0] ?? "")} className={control}>
138− {spec.options.map((option) => (
139− <option key={option}>{option}</option>
140− ))}
141− </select>
142− </label>
140+ <Select name={`input.${name}`} defaultValue={String(spec.default ?? spec.options[0] ?? "") || undefined}>
141+ <SelectTrigger size="sm" aria-label={spec.description || name}>
142+ <SelectValue placeholder="Choose" />
143+ </SelectTrigger>
144+ <SelectContent>
145+ {/* A Radix item cannot have the value "". */}
146+ {spec.options.filter((option) => option !== "").map((option) => (
147+ <SelectItem key={option} value={option}>
148+ {option}
149+ </SelectItem>
150+ ))}
151+ </SelectContent>
152+ </Select>
153+ </div>
143154 );
144155 }
145156 return (
+3−8
66 import type { Route } from "./+types/issue-new";
77 import { page } from "../../lib/meta";
88 import { Button, ErrorText, Field, Input, Textarea } from "../../components/ui";
9+import { CheckboxOption } from "../../components/ui/checkbox";
910 import { Label } from "../../components/work";
1011 import { integrations, work } from "../../lib/services.server";
1112 import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
7980 <div className="min-w-56 grow">
8081 <Input name="reference" required placeholder="TECH-1234" aria-label="Ticket key or address" />
8182 </div>
82− <label className="flex items-center gap-1.5 text-sm text-muted">
83− <input type="checkbox" name="assign" className="accent-accent" />
84− Put an agent on it
85− </label>
83+ <CheckboxOption name="assign" label="Put an agent on it" className="items-center" labelClassName="text-muted" />
8684 <Button type="submit" variant="quiet" disabled={busy}>
8785 Import
8886 </Button>
114112 <legend className="mb-1.5 text-sm font-medium text-muted">Labels</legend>
115113 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
116114 {loaderData.labels.map((name) => (
117− <label key={name} className="flex cursor-pointer items-center gap-1.5">
118− <input type="checkbox" name="label" value={name} className="accent-accent" />
119− <Label name={name} />
120− </label>
115+ <CheckboxOption key={name} name="label" value={name} label={<Label name={name} />} className="items-center gap-1.5" />
121116 ))}
122117 </div>
123118 <div className="mt-2">
+9−10
1818 Textarea,
1919 TimeAgo,
2020 } from "../../components/ui";
21+import { CheckboxOption } from "../../components/ui/checkbox";
2122 import { CheckBadge } from "../../components/checks";
2223 import {
2324 Assignee,
609610 <input type="hidden" name="action" value="labels" />
610611 <div className="flex flex-wrap gap-x-3 gap-y-2">
611612 {labels.map((name) => (
612− <label key={name} className="flex cursor-pointer items-center gap-1.5">
613− <input
614− type="checkbox"
615− name="label"
616− value={name}
617− defaultChecked={issue.labels.includes(name)}
618− className="accent-accent"
619− />
620− <Label name={name} />
621− </label>
613+ <CheckboxOption
614+ key={name}
615+ name="label"
616+ value={name}
617+ defaultChecked={issue.labels.includes(name)}
618+ label={<Label name={name} />}
619+ className="items-center gap-1.5"
620+ />
622621 ))}
623622 </div>
624623 <Input name="labels" placeholder="New labels, comma separated" />
+4−4
55 import type { Route } from "./+types/issues";
66 import { page } from "../../lib/meta";
77 import { Button, ButtonLink, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
8+import { Checkbox } from "../../components/ui/checkbox";
89 import {
910 Assignee,
1011 AssigneeStack,
153154 {issues.map((issue) => (
154155 <li key={issue.id} className="flex items-start transition-colors hover:bg-surface">
155156 {assignable && (
156− <input
157− type="checkbox"
157+ <Checkbox
158158 name="issue"
159− value={issue.number}
159+ value={String(issue.number)}
160160 // Already being worked on: nothing more to hand over.
161161 disabled={issue.agent != null || issue.queued}
162162 aria-label={`Select issue #${issue.number}`}
163− className="mt-4 ml-4 accent-accent disabled:opacity-30"
163+ className="mt-4 ml-4 disabled:opacity-30"
164164 />
165165 )}
166166 <Link
+4−4
1010 import { Activity, Exchanges } from "../../components/activity";
1111 import { Outcome } from "../../components/outcome";
1212 import { Button, ErrorText, TimeAgo } from "../../components/ui";
13+import { Checkbox } from "../../components/ui/checkbox";
1314 import { Label } from "../../components/work";
1415 import { billing, events, identity, work } from "../../lib/services.server";
1516 import {
185186 className="flex gap-3 rounded-xl border border-line bg-surface p-4"
186187 >
187188 {plan.status === "ready" ? (
188− <input
189− type="checkbox"
189+ <Checkbox
190190 name="keep"
191− value={position}
191+ value={String(position)}
192192 defaultChecked
193193 aria-label={`Open issue ${position}`}
194− className="mt-1.5 accent-accent"
194+ className="mt-1"
195195 />
196196 ) : (
197197 <span className="mt-0.5 w-5 shrink-0 text-center font-mono text-sm text-faint">
+7−9
33
44 import type { Route } from "./+types/pull-new";
55 import { page } from "../../lib/meta";
6+import { Combobox } from "../../components/ui/combobox";
67 import {
78 Button,
89 CopyLine,
9394 <div className="flex flex-wrap items-center gap-2 rounded-xl border border-line bg-surface px-4 py-3 text-sm">
9495 <GitBranch size={15} className="text-faint" />
9596 <span className="text-muted">Merge</span>
96− <select
97+ <Combobox
9798 name="branch"
9899 defaultValue={branches.includes(selected) ? selected : branches[0]}
99100 aria-label="Branch to merge"
100− className="rounded-md border border-line bg-bg px-2 py-1 font-mono text-sm"
101− >
102− {branches.map((name) => (
103− <option key={name} value={name}>
104− {name}
105− </option>
106− ))}
107− </select>
101+ searchPlaceholder="Find a branch"
102+ emptyText="No branch by that name."
103+ options={branches.map((name) => ({ value: name, label: name, icon: <GitBranch /> }))}
104+ className="h-8 w-auto max-w-full min-w-40 font-mono"
105+ />
108106 <span className="text-muted">into</span>
109107 <span className="font-mono">{defaultBranch}</span>
110108 </div>
+11−19
5050 Textarea,
5151 TimeAgo,
5252 } from "../../components/ui";
53+import { CheckboxOption } from "../../components/ui/checkbox";
5354 import { WorkflowStatuses } from "../../components/actions";
5455 import { ChecksPanel } from "../../components/checks";
5556 import {
850851 {canMerge && !landing && lifecycle?.stage !== "queued" && (
851852 <Form method="post" className="space-y-3 px-4 py-3">
852853 {issue?.state === "open" && (
853− <label className="flex items-start gap-2 text-xs text-muted">
854− <input
855− type="checkbox"
856− name="keepIssueOpen"
857− className="mt-0.5 accent-accent"
858− />
859− <span>Keep #{issue.number} open. This is only part of the work.</span>
860− </label>
854+ <CheckboxOption
855+ name="keepIssueOpen"
856+ label={`Keep #${issue.number} open. This is only part of the work.`}
857+ labelClassName="text-xs text-muted"
858+ />
861859 )}
862860 {unchecked && canIgnoreChecks && (
863− <label className="flex items-start gap-2 text-xs text-muted">
864− <input
865− type="checkbox"
866− name="ignoreChecks"
867− className="mt-0.5 accent-accent"
868− />
869− <span>
870− Merge although the checks{" "}
871− {checking ? "have not finished" : "did not pass"}.
872− </span>
873− </label>
861+ <CheckboxOption
862+ name="ignoreChecks"
863+ label={`Merge although the checks ${checking ? "have not finished" : "did not pass"}.`}
864+ labelClassName="text-xs text-muted"
865+ />
874866 )}
875867 <div className="flex flex-wrap items-center gap-3">
876868 <Button
+19−8
66 import type { Route } from "./+types/settings-dependencies";
77 import { page } from "../../lib/meta";
88 import { RepoSettingsTabs } from "../../components/repo-settings-tabs";
9−import { Button, EmptyState, ErrorText, Field, Input } from "../../components/ui";
9+import { Avatar, Button, EmptyState, ErrorText, Field, Input } from "../../components/ui";
10+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
1011 import { projects } from "../../lib/services.server";
1112 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
1213
121122 <Form method="post" className="mt-4 grid items-end gap-3 rounded-xl border border-line bg-surface p-4 sm:grid-cols-[1fr_1fr_auto]">
122123 <input type="hidden" name="intent" value="add" />
123124 <Field label="Project">
124− <select name="on" required className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm">
125− {choices.map((project) => (
126− <option key={project.slug} value={project.slug}>
127− {project.name}
128− </option>
129− ))}
130− </select>
125+ <Select name="on" required defaultValue={choices[0].slug}>
126+ <SelectTrigger aria-label="Project" className="h-auto py-2">
127+ <SelectValue />
128+ </SelectTrigger>
129+ <SelectContent>
130+ {choices.map((project) => (
131+ <SelectItem
132+ key={project.slug}
133+ value={project.slug}
134+ icon={<Avatar name={project.name} size={16} square />}
135+ description={project.slug !== project.name ? project.slug : undefined}
136+ >
137+ {project.name}
138+ </SelectItem>
139+ ))}
140+ </SelectContent>
141+ </Select>
131142 </Field>
132143 <Field label="Its address as">
133144 <Input name="as" placeholder="API_URL" className="font-mono" />
+4−7
44 import { page } from "../../lib/meta";
55 import { RepoSettingsTabs } from "../../components/repo-settings-tabs";
66 import { Button, ErrorText, Field, Input } from "../../components/ui";
7+import { SwitchCard } from "../../components/ui/switch";
78 import { deployments } from "../../lib/services.server";
89 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
910
4344
4445 function Check({ name, on, title, children }: { name: string; on: boolean; title: string; children: string }) {
4546 return (
46− <label className="flex cursor-pointer items-start gap-3 rounded-xl border border-line bg-surface p-4 hover:border-line-strong">
47− <input type="checkbox" name={name} defaultChecked={on} className="mt-1 accent-accent" />
48− <span>
49− <span className="block text-sm font-medium">{title}</span>
50− <span className="mt-1 block text-sm text-muted">{children}</span>
51− </span>
52− </label>
47+ <SwitchCard name={name} defaultChecked={on} title={title}>
48+ {children}
49+ </SwitchCard>
5350 );
5451 }
5552
+29−46
1+import { Globe, Lock } from "lucide-react";
12 import type { ReactNode } from "react";
23 import { Form, data, useNavigation } from "react-router";
34
56 import type { Route } from "./+types/settings-repository";
67 import { page } from "../../lib/meta";
78 import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui";
9+import { RadioCard, RadioGroup } from "../../components/ui/radio-group";
10+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
11+import { SwitchCard } from "../../components/ui/switch";
812 import { repos, work } from "../../lib/services.server";
913 import {
1014 assertSameOrigin,
99103 children: ReactNode;
100104 }) {
101105 return (
102− <label className="flex cursor-pointer items-start gap-3 rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
103− <input type="checkbox" name={name} defaultChecked={on} className="mt-1 accent-accent" />
104− <span>
105− <span className="block text-sm font-medium">{title}</span>
106− <span className="mt-1 block text-sm text-muted">{children}</span>
107− </span>
108− </label>
106+ <SwitchCard name={name} defaultChecked={on} title={title}>
107+ {children}
108+ </SwitchCard>
109109 );
110110 }
111111
129129 <p className="text-sm font-medium">{title}</p>
130130 <p className="mt-1 text-sm text-muted">{children}</p>
131131 </div>
132− <select
133− name={name}
134− defaultValue={value}
135− aria-label={title}
136− className="shrink-0 rounded-md border border-line bg-bg px-2 py-1.5 text-sm"
137− >
138− {options.map(([option, label]) => (
139− <option key={option} value={option}>
140− {label}
141− </option>
142− ))}
143− </select>
132+ <Select name={name} defaultValue={String(value)}>
133+ <SelectTrigger size="sm" aria-label={title} className="w-auto min-w-28 shrink-0">
134+ <SelectValue />
135+ </SelectTrigger>
136+ <SelectContent align="end">
137+ {options.map(([option, label]) => (
138+ <SelectItem key={option} value={String(option)}>
139+ {label}
140+ </SelectItem>
141+ ))}
142+ </SelectContent>
143+ </Select>
144144 </div>
145145 );
146146 }
157157 <Field label="Description">
158158 <Input name="description" maxLength={200} defaultValue={repo.description ?? ""} />
159159 </Field>
160− <fieldset className="space-y-2 rounded-xl border border-line bg-surface p-4 text-sm">
160+ <fieldset>
161161 <legend className="sr-only">Visibility</legend>
162− <label className="flex items-start gap-3">
163− <input
164− type="radio"
165− name="visibility"
166− value="public"
167− defaultChecked={!repo.isPrivate}
168− className="mt-1 accent-accent"
169− />
170− <span>
171− <span className="block font-medium">Public</span>
172− <span className="text-muted">Anyone can see and clone it.</span>
173− </span>
174− </label>
175− <label className="flex items-start gap-3">
176− <input
177− type="radio"
178− name="visibility"
179− value="private"
180− defaultChecked={repo.isPrivate}
181− className="mt-1 accent-accent"
182− />
183− <span>
184− <span className="block font-medium">Private</span>
185− <span className="text-muted">Only members of the workspace can see it.</span>
186− </span>
187− </label>
162+ <RadioGroup
163+ name="visibility"
164+ defaultValue={repo.isPrivate ? "private" : "public"}
165+ aria-label="Visibility"
166+ className="gap-3 sm:grid-cols-2"
167+ >
168+ <RadioCard value="public" icon={<Globe />} title="Public" description="Anyone can see and clone it." />
169+ <RadioCard value="private" icon={<Lock />} title="Private" description="Only members of the workspace can see it." />
170+ </RadioGroup>
188171 </fieldset>
189172 </Section>
190173
+24−0
33
44 import type { Route } from "./+types/settings";
55 import { page } from "../lib/meta";
6+import { AvatarField } from "../components/avatar-field";
67 import { Button, ErrorText, Field, Input, TimeAgo } from "../components/ui";
8+import { readAvatarUpload } from "../lib/avatar-upload";
79 import { assertSameOrigin, requireUser } from "../lib/session.server";
810
911 export function meta(args: Route.MetaArgs) {
2729 const id = String(form.get("id") ?? "");
2830
2931 switch (form.get("intent")) {
32+ // The picture: identity checks the image's bytes again.
33+ case "avatar": {
34+ const upload = await readAvatarUpload(form);
35+ if ("error" in upload) return { avatarError: upload.error };
36+ const result = await identity.setUserAvatar(user, upload.image);
37+ return result.ok ? null : { avatarError: result.error.message };
38+ }
39+ case "remove-avatar": {
40+ const result = await identity.setUserAvatar(user, null);
41+ return result.ok ? null : { avatarError: result.error.message };
42+ }
3043 case "add-key": {
3144 const result = await identity.addSshKey(
3245 user,
89102 </p>
90103 </header>
91104 <div className="max-w-2xl space-y-12">
105+ <section id="picture" className="scroll-mt-20">
106+ <h2 className="font-medium">Picture</h2>
107+ <div className="mt-4">
108+ <AvatarField
109+ name={user.username}
110+ image={user.avatar}
111+ error={actionData && "avatarError" in actionData ? actionData.avatarError : undefined}
112+ about="Shown beside your username in the sidebar and on mission control. Without one, g1t draws your first letter."
113+ />
114+ </div>
115+ </section>
92116 <section id="ssh-keys" className="scroll-mt-20">
93117 <h2 className="font-medium">SSH keys</h2>
94118 <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden">
+9−8
1313
1414 import type { Route } from "./+types/billing";
1515 import { page } from "../../lib/meta";
16+import { RadioGroup, RadioGroupItem } from "../../components/ui/radio-group";
1617 import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
1718 import { billing, deployments } from "../../lib/services.server";
1819 import {
636637 <Form method="post" className="mt-5 border-t border-line pt-4">
637638 <fieldset>
638639 <legend className="text-xs text-muted">Your monthly spend limit</legend>
639− <div className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
640− <label className="flex items-center gap-2">
641− <input type="radio" name="mode" value="automatic" defaultChecked={mode === "automatic"} className="accent-accent" />
640+ <RadioGroup name="mode" defaultValue={mode} className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
641+ <label className="flex cursor-pointer items-center gap-2">
642+ <RadioGroupItem value="automatic" />
642643 Automatic
643644 </label>
644− <label className="flex items-center gap-2">
645− <input type="radio" name="mode" value="fixed" defaultChecked={mode === "fixed"} className="accent-accent" />
645+ <label className="flex cursor-pointer items-center gap-2">
646+ <RadioGroupItem value="fixed" />
646647 Fixed at
647648 <span className="flex items-center rounded-md border border-line bg-bg px-2 focus-within:border-accent">
648649 <span className="text-muted">$</span>
657658 />
658659 </span>
659660 </label>
660− <label className="flex items-center gap-2">
661− <input type="radio" name="mode" value="none" defaultChecked={mode === "none"} className="accent-accent" />
661+ <label className="flex cursor-pointer items-center gap-2">
662+ <RadioGroupItem value="none" />
662663 None
663664 </label>
664665 <Button variant="quiet" type="submit" name="intent" value="spend-limit" disabled={busy}>
665666 Save
666667 </Button>
667− </div>
668+ </RadioGroup>
668669 </fieldset>
669670 <ErrorText>{error}</ErrorText>
670671 <p className="mt-3 text-xs text-faint">
+23−21
2525 import type { Route } from "./+types/integrations";
2626 import { page } from "../../lib/meta";
2727 import { MODEL_CATALOG, ModelCatalog, ModelProviderFields, ProviderMark, ProviderTiles, Routing } from "../../components/model-providers";
28−import { Button, CopyLine, ErrorText, Field, Input, TimeAgo } from "../../components/ui";
28+import { Avatar, Button, CopyLine, ErrorText, Field, Input, TimeAgo } from "../../components/ui";
29+import { CheckboxOption } from "../../components/ui/checkbox";
30+import { Combobox } from "../../components/ui/combobox";
2931 import { billing, integrations, repos } from "../../lib/services.server";
3032 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
3133
487489 function RepoField({ repos, required, hint }: { repos: string[]; required?: boolean; hint: string }) {
488490 return (
489491 <Field label="Repository" hint={hint}>
490− <select
492+ <Combobox
491493 name="repo"
492− required={required}
493494 defaultValue={repos[0] ?? ""}
494− className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none hover:border-line-strong focus:border-accent-dim"
495− >
496− {!required && <option value="">None</option>}
497− {repos.map((repo) => (
498− <option key={repo} value={repo}>
499− {repo}
500− </option>
501− ))}
502− </select>
495+ aria-label="Repository"
496+ placeholder={repos.length === 0 ? "No repositories yet" : "Choose a repository"}
497+ searchPlaceholder="Find a repository"
498+ emptyText="No repository by that name."
499+ options={[
500+ ...(required ? [] : [{ value: "", label: "None" }]),
501+ ...repos.map((repo) => ({ value: repo, label: repo, icon: <Avatar name={repo.split("/").pop() ?? repo} size={16} square /> })),
502+ ]}
503+ className="font-mono text-[0.8125rem]"
504+ />
503505 </Field>
504506 );
505507 }
506508
507509 function AssignField() {
508510 return (
509− <label className="flex items-start gap-2.5 text-sm">
510− <input type="checkbox" name="assign" className="mt-1 accent-[var(--color-accent)]" />
511− <span>
512− <span className="font-medium">Put a g1t agent on each new issue</span>
513− <span className="block text-xs text-muted">
514− It opens a pull request, gets reviewed, and lands through your merge rules, before anyone has
515− to look. Agents run only where g1t agents are enabled.
511+ <CheckboxOption
512+ name="assign"
513+ label={<span className="font-medium">Put a g1t agent on each new issue</span>}
514+ description={
515+ <span className="text-muted">
516+ It opens a pull request, gets reviewed, and lands through your merge rules, before anyone has to look.
517+ Agents run only where g1t agents are enabled.
516518 </span>
517− </span>
518− </label>
519+ }
520+ />
519521 );
520522 }
521523
+2−2
1919
2020 /** A workspace's own pages, each with its title and what it is for. */
2121 const PAGES: Record<string, { title: string; about: string }> = {
22− settings: { title: "General", about: "The workspace's name, address and description." },
22+ settings: { title: "General", about: "The workspace's name, icon, address and description." },
2323 people: {
2424 title: "Members",
2525 about: "Members create repositories, push, manage issues and merge pull requests. Owners also manage members, tokens, billing and integrations.",
6868 <div className="border-b border-line bg-surface/60">
6969 <div className="mx-auto max-w-6xl px-4 pt-8">
7070 <div className="flex flex-wrap items-center gap-4">
71− <Avatar name={workspace.slug} size={52} square />
71+ <Avatar name={workspace.slug} image={workspace.avatar} size={52} square />
7272 <div className="min-w-0 grow">
7373 <div className="flex flex-wrap items-center gap-2.5">
7474 <h1 className="truncate text-2xl font-semibold tracking-tight">
+53−21
22
33 import type { Route } from "./+types/settings";
44 import { page } from "../../lib/meta";
5+import { AvatarField } from "../../components/avatar-field";
56 import { Button, ErrorText, Field, Input } from "../../components/ui";
7+import { readAvatarUpload } from "../../lib/avatar-upload";
68 import { identity } from "../../lib/services.server";
79 import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
810
2426 assertSameOrigin(request);
2527 const user = requireUser(context, request);
2628 const form = await request.formData();
29+ const intent = form.get("intent");
30+ // The icon: identity checks the owner and the image's bytes again.
31+ if (intent === "avatar" || intent === "remove-avatar") {
32+ let image: string | null = null;
33+ if (intent === "avatar") {
34+ const upload = await readAvatarUpload(form);
35+ if ("error" in upload) return { avatarError: upload.error };
36+ image = upload.image;
37+ }
38+ const result = await identity.setWorkspaceAvatar(user, params.owner, image);
39+ if (!result.ok) return { avatarError: result.error.message };
40+ return { saved: "avatar" as const };
41+ }
2742 const result = await identity.updateWorkspace(user, params.owner, {
2843 name: String(form.get("displayName") ?? ""),
2944 description: String(form.get("description") ?? ""),
3550 export default function WorkspaceSettings({ loaderData, actionData }: Route.ComponentProps) {
3651 const { workspace } = loaderData;
3752 return (
38− <div className="max-w-lg">
39− <h2 className="font-medium">Workspace details</h2>
40− <Form method="post" className="mt-5 space-y-4">
41− <Field label="Display name">
42− <Input name="displayName" maxLength={80} defaultValue={workspace.name} />
43− </Field>
44− <Field label="Description" hint="One line, shown at the top of the workspace's page.">
45− <Input
46− name="description"
47− maxLength={160}
48− defaultValue={workspace.description ?? ""}
53+ <div className="max-w-lg space-y-10">
54+ <section>
55+ <h2 className="font-medium">Icon</h2>
56+ <div className="mt-4">
57+ <AvatarField
58+ name={workspace.slug}
59+ image={workspace.avatar}
60+ square
61+ error={actionData && "avatarError" in actionData ? actionData.avatarError : undefined}
62+ about="Shown beside the workspace's name everywhere on g1t, and on its link previews. Without one, g1t draws its first letter."
4963 />
50− </Field>
51− <Field
52− label="Name in URLs"
53− hint="This cannot be changed: repository addresses and clones depend on it."
54− >
55− <p className="font-mono text-sm text-muted">g1t.sh/{workspace.slug}</p>
56− </Field>
57− <ErrorText>{actionData?.error}</ErrorText>
58− <Button type="submit">Save</Button>
59− </Form>
64+ </div>
65+ </section>
66+
67+ <section>
68+ <h2 className="font-medium">Workspace details</h2>
69+ <Form method="post" className="mt-5 space-y-4">
70+ <Field
71+ label="Display name"
72+ hint="How people see the workspace: in the sidebar, at the top of its page and in link previews. Up to 80 characters; spaces and capitals are fine."
73+ >
74+ <Input name="displayName" maxLength={80} defaultValue={workspace.name} placeholder={workspace.slug} />
75+ </Field>
76+ <Field label="Description" hint="One line, shown at the top of the workspace's page.">
77+ <Input name="description" maxLength={160} defaultValue={workspace.description ?? ""} />
78+ </Field>
79+ <ErrorText>{actionData && "error" in actionData ? actionData.error : undefined}</ErrorText>
80+ <Button type="submit">Save</Button>
81+ </Form>
82+ </section>
83+
84+ <section>
85+ <h2 className="font-medium">Address</h2>
86+ <p className="mt-3 font-mono text-sm text-fg">g1t.sh/{workspace.slug}</p>
87+ <p className="mt-1.5 text-xs text-faint">
88+ The workspace's slug: lowercase, in every repository's address and every clone URL. It cannot be
89+ changed, so links and clones keep working. Change the display name instead.
90+ </p>
91+ </section>
6092 </div>
6193 );
6294 }
+1−0
1717 "@g1t/theme": "*",
1818 "class-variance-authority": "^0.7.1",
1919 "clsx": "^2.1.1",
20+ "cmdk": "^1.1.1",
2021 "isbot": "^5.1.36",
2122 "lucide-react": "^1.49.0",
2223 "mdast-util-find-and-replace": "^3.0.2",
+40−0
66 );
77
88 const GIT_PATH = /\/(info\/refs|git-upload-pack|git-receive-pack)$/;
9+/** An uploaded avatar, by the SHA-256 of its bytes. */
10+const AVATAR_PATH = /^\/avatars\/([0-9a-f]{64})$/;
11+/** The only types identity stores, having checked each image's bytes. */
12+const AVATAR_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
913 const DOCS = "https://docs.g1t.sh";
1014
1115 /** Where the documentation pages that used to live under /docs are now. */
2731 if (GIT_PATH.test(pathname)) {
2832 return env.REPOS.fetch(request);
2933 }
34+ const avatar = AVATAR_PATH.exec(pathname);
35+ if (avatar) {
36+ return serveAvatar(env, avatar[1], request.method);
37+ }
3038 // The documentation is its own site.
3139 if (pathname === "/docs" || pathname.startsWith("/docs/")) {
3240 const page = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
3644 return requestHandler(request);
3745 },
3846 } satisfies ExportedHandler<Env>;
47+
48+/**
49+ * An uploaded avatar. Its address is its hash, so it never changes and is
50+ * kept for good. It is served as nothing but an image: the stored type,
51+ * no sniffing, and a policy that lets nothing in it run.
52+ */
53+async function serveAvatar(env: Env, hash: string, method: string): Promise<Response> {
54+ if (method !== "GET" && method !== "HEAD") {
55+ return new Response("Method not allowed", { status: 405, headers: { allow: "GET, HEAD" } });
56+ }
57+ const { value, metadata } = await env.AVATARS.getWithMetadata<{ contentType?: string }>(hash, {
58+ type: "arrayBuffer",
59+ cacheTtl: 86400,
60+ });
61+ const contentType = metadata?.contentType;
62+ if (!value || !contentType || !AVATAR_TYPES.has(contentType)) {
63+ return new Response("Not found", {
64+ status: 404,
65+ headers: { "cache-control": "public, max-age=60" },
66+ });
67+ }
68+ return new Response(method === "HEAD" ? null : value, {
69+ headers: {
70+ "content-type": contentType,
71+ "content-length": String(value.byteLength),
72+ "cache-control": "public, max-age=31536000, immutable",
73+ "x-content-type-options": "nosniff",
74+ "content-security-policy": "default-src 'none'; sandbox",
75+ "cross-origin-resource-policy": "cross-origin",
76+ },
77+ });
78+}
+2−0
1616 DEPLOYMENTS: ServiceBinding;
1717 PROJECTS: ServiceBinding;
1818 BLOBS: KVNamespace;
19+ /** Uploaded avatars by SHA-256, with `{ contentType }`; written by identity. */
20+ AVATARS: KVNamespace;
1921 }
2022 }
2123 interface Env extends Cloudflare.Env {}
+6−1
1010 "routes": [{ "pattern": "g1t.sh", "custom_domain": true }],
1111 // The site holds no data of its own; everything goes through services.
1212 // GitHub Actions artifacts, as the API keeps them, for download from a run.
13− "kv_namespaces": [{ "binding": "BLOBS", "id": "16a4232cb746418db53782aa068be693" }],
13+ // Uploaded avatars (g1t-avatars), served at /avatars/<sha256>. Read
14+ // only here: the identity service checks and writes them.
15+ "kv_namespaces": [
16+ { "binding": "BLOBS", "id": "16a4232cb746418db53782aa068be693" },
17+ { "binding": "AVATARS", "id": "e627b571f07047e187c03e1fc2b3bbdd" }
18+ ],
1419 "services": [
1520 { "binding": "IDENTITY", "service": "g1t-identity" },
1621 { "binding": "REPOS", "service": "g1t-repos" },
+25−0
242242 /// RFC 3339.
243243 pub created_at: String,
244244 pub member_count: u32,
245+ /// The workspace's uploaded icon: the SHA-256 of its bytes, served at
246+ /// `/avatars/<avatar>`. Null means the generated letter avatar.
247+ #[serde(default)]
248+ pub avatar: Option<String>,
245249 }
246250
247251 #[derive(Clone, Debug, Serialize, Deserialize)]
291295 pub description: String,
292296 }
293297
298+/// `set_workspace_avatar`: owners only. `image` is the file's bytes in
299+/// base64: PNG, JPEG, WebP or GIF, at most `MAX_AVATAR_BYTES`. Null removes
300+/// the icon. Returns `Outcome<Workspace>`.
301+#[derive(Debug, Serialize, Deserialize)]
302+pub struct SetWorkspaceAvatarArgs {
303+ pub actor: User,
304+ pub slug: String,
305+ pub image: Option<String>,
306+}
307+
308+/// `set_user_avatar`: a person's own avatar, as `SetWorkspaceAvatarArgs`.
309+/// Returns `Outcome<Option<String>>`: the new avatar, or null once removed.
310+#[derive(Debug, Serialize, Deserialize)]
311+pub struct SetUserAvatarArgs {
312+ pub user: User,
313+ pub image: Option<String>,
314+}
315+
316+/// The largest avatar that can be uploaded, in bytes.
317+pub const MAX_AVATAR_BYTES: usize = 1024 * 1024;
318+
294319 /// `list_workspace_tokens`: members only. Returns
295320 /// `Outcome<Vec<AccessToken>>`.
296321 #[derive(Debug, Serialize, Deserialize)]
+25−0
4040 /// The workspace's name in URLs: `g1t.sh/<slug>`.
4141 pub slug: String,
4242 pub role: Role,
43+ /// The workspace's display name, for showing it to people. Set when a
44+ /// user is resolved from credentials; absent on principals made up by
45+ /// a service.
46+ #[serde(default, skip_serializing_if = "Option::is_none")]
47+ pub name: Option<String>,
48+ /// The workspace's uploaded icon: the SHA-256 of its bytes, served at
49+ /// `/avatars/<avatar>`. Absent means the generated letter avatar.
50+ #[serde(default, skip_serializing_if = "Option::is_none")]
51+ pub avatar: Option<String>,
4352 }
4453
54+impl Membership {
55+ /// A plain member of `slug`, as services act inside one workspace.
56+ pub fn member(slug: impl Into<String>) -> Self {
57+ Membership {
58+ slug: slug.into(),
59+ role: Role::Member,
60+ name: None,
61+ avatar: None,
62+ }
63+ }
64+}
65+
4566 /// What a set of credentials resolved to.
4667 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
4768 #[serde(rename_all = "lowercase")]
7394 /// resolved from credentials, so any service can authorize from it.
7495 #[serde(default)]
7596 pub workspaces: Vec<Membership>,
97+ /// The person's uploaded avatar: the SHA-256 of its bytes, served at
98+ /// `/avatars/<avatar>`. Absent means the generated letter avatar.
99+ #[serde(default, skip_serializing_if = "Option::is_none")]
100+ pub avatar: Option<String>,
76101 }
77102
78103 impl User {
+1−0
1414 "issues",
1515 "oauth",
1616 "assets",
17+ "avatars",
1718 "docs",
1819 "explore",
1920 "g1t",
+20−3
6060 "@g1t/theme": "*",
6161 "class-variance-authority": "^0.7.1",
6262 "clsx": "^2.1.1",
63+ "cmdk": "^1.1.1",
6364 "isbot": "^5.1.36",
6465 "lucide-react": "^1.49.0",
6566 "mdast-util-find-and-replace": "^3.0.2",
52825283 "node": ">=6"
52835284 }
52845285 },
5286+ "node_modules/cmdk": {
5287+ "version": "1.1.1",
5288+ "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
5289+ "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
5290+ "license": "MIT",
5291+ "dependencies": {
5292+ "@radix-ui/react-compose-refs": "^1.1.1",
5293+ "@radix-ui/react-dialog": "^1.1.6",
5294+ "@radix-ui/react-id": "^1.1.0",
5295+ "@radix-ui/react-primitive": "^2.0.2"
5296+ },
5297+ "peerDependencies": {
5298+ "react": "^18 || ^19 || ^19.0.0-rc",
5299+ "react-dom": "^18 || ^19 || ^19.0.0-rc"
5300+ }
5301+ },
52855302 "node_modules/color-name": {
52865303 "version": "1.1.4",
52875304 "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
59175934 }
59185935 },
59195936 "node_modules/fflate": {
5920− "version": "0.7.3",
5921− "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.3.tgz",
5922− "integrity": "sha512-0Zz1jOzJWERhyhsimS54VTqOteCNwRtIlh8isdL0AXLo0g7xNTfTL7oWrkmCnPhZGocKIkWHBistBrrpoNH3aw==",
5937+ "version": "0.7.5",
5938+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.5.tgz",
5939+ "integrity": "sha512-QieYf//cis6ywHNi5qW1+PXPQ4bC+XVJAtS4AXIML8P76GroEiOxm/oQtn1f02UkJY1+KsXMJcC+R2v/Eg4G3g==",
59235940 "license": "MIT"
59245941 },
59255942 "node_modules/find-proc": {
+8−1
22 "name": "g1t-monorepo",
33 "private": true,
44 "type": "module",
5− "workspaces": ["apps/*", "services/*", "packages/*"],
5+ "workspaces": [
6+ "apps/*",
7+ "services/*",
8+ "packages/*"
9+ ],
610 "scripts": {
711 "typecheck": "npm run typecheck --workspaces --if-present",
812 "deploy": "npm run deploy -w @g1t/web"
1014 "devDependencies": {
1115 "typescript": "^5.9.3",
1216 "wrangler": "^4.146.0"
17+ },
18+ "overrides": {
19+ "fflate": "^0.7.5"
1320 }
1421 }
+2−0
6767 call("remove_member", { actor, slug, username }),
6868 updateWorkspace: (actor, slug, details) =>
6969 call("update_workspace", { actor, slug, ...details }),
70+ setWorkspaceAvatar: (actor, slug, image) => call("set_workspace_avatar", { actor, slug, image }),
71+ setUserAvatar: (user, image) => call("set_user_avatar", { user, image }),
7072 listWorkspaceTokens: (slug, viewer) => call("list_workspace_tokens", { slug, viewer }),
7173 createWorkspaceToken: (actor, slug, name) =>
7274 call("create_workspace_token", { actor, slug, name }),
+30−1
2020 * credentials, so any service can authorize from it.
2121 */
2222 workspaces?: Membership[];
23+ /**
24+ * The person's uploaded avatar: the SHA-256 of its bytes, served at
25+ * `/avatars/<avatar>`. Only set on the signed-in person; absent means
26+ * the generated letter avatar.
27+ */
28+ avatar?: string;
2329 };
2430
2531 /** What a member may do: an owner also manages the workspace's members. */
2632 export type Role = "owner" | "member";
2733
28−export type Membership = { slug: string; role: Role };
34+export type Membership = {
35+ /** The workspace's name in URLs: `g1t.sh/<slug>`. */
36+ slug: string;
37+ role: Role;
38+ /** Its display name. Set on users resolved from credentials. */
39+ name?: string;
40+ /** Its uploaded icon, as `Workspace.avatar`. */
41+ avatar?: string;
42+};
2943
44+/** The largest avatar that can be uploaded, in bytes. */
45+export const MAX_AVATAR_BYTES = 1024 * 1024;
46+
3047 /**
3148 * A workspace: the owner of repositories, and the first segment of their
3249 * URLs. A person's own space and a team's are the same thing.
4057 /** RFC 3339. */
4158 createdAt: string;
4259 memberCount: number;
60+ /**
61+ * The workspace's uploaded icon: the SHA-256 of its bytes, served at
62+ * `/avatars/<avatar>`. Null means the generated letter avatar.
63+ */
64+ avatar: string | null;
4365 };
4466
4567 export type Member = { username: string; role: Role };
209231 removeMember(actor: User, slug: string, username: string): Promise<Result<boolean>>;
210232 /** Owners only. An empty name falls back to the slug. */
211233 updateWorkspace(actor: User, slug: string, details: { name: string; description: string }): Promise<Result<Workspace>>;
234+ /**
235+ * Owners only. `image` is the file in base64: PNG, JPEG, WebP or GIF, at
236+ * most `MAX_AVATAR_BYTES`, checked by its bytes. Null removes the icon.
237+ */
238+ setWorkspaceAvatar(actor: User, slug: string, image: string | null): Promise<Result<Workspace>>;
239+ /** A person's own avatar, as `setWorkspaceAvatar`: the new one, or null. */
240+ setUserAvatar(user: User, image: string | null): Promise<Result<string | null>>;
212241
213242 /**
214243 * A workspace's own access tokens. They belong to the workspace, act as
+1−1
66 /** Routes and reserved words that may not be registered as usernames. */
77 const RESERVED = new Set([
88 "api", "mcp", "login", "logout", "register", "new", "settings", "search",
9− "admin", "auth", "pulls", "issues", "verify", "forgot", "reset", "device", "workspaces", "u", "oauth", "assets", "docs", "explore", "g1t", "about", "pricing",
9+ "admin", "auth", "pulls", "issues", "verify", "forgot", "reset", "device", "workspaces", "u", "oauth", "assets", "avatars", "docs", "explore", "g1t", "about", "pricing",
1010 ]);
1111
1212 export function isValidNamespace(value: string): boolean {
+3−5
2525 use g1t_contracts::events::Event;
2626 use g1t_contracts::identity::{SlugArgs, Workspace};
2727 use g1t_contracts::repos::{GetArgs, GetByIdArgs, Repo, RepoPath};
28−use g1t_contracts::{FailureCode, Membership, Outcome, PrincipalKind, Role, User, Viewer};
28+use g1t_contracts::{FailureCode, Membership, Outcome, PrincipalKind, User, Viewer};
2929 use g1t_kit::{args, reply, rpc_method};
3030 use g1t_secrets::Sealer;
3131 use serde::Deserialize;
102102 username: workspace.slug.clone(),
103103 kind: PrincipalKind::Workspace,
104104 verified: true,
105− workspaces: vec![Membership {
106− slug: workspace.slug,
107− role: Role::Member,
108− }],
105+ workspaces: vec![Membership::member(workspace.slug)],
106+ ..User::default()
109107 }))
110108 }
111109
+6−0
1+-- Uploaded icons, as GitHub's organizations and people have. Each is the
2+-- SHA-256 of the image's bytes, which is also its key in the g1t-avatars
3+-- KV namespace, so an address never serves a stale image. Null means the
4+-- generated letter avatar.
5+ALTER TABLE workspaces ADD COLUMN avatar TEXT;
6+ALTER TABLE users ADD COLUMN avatar TEXT;
+256−0
1+//! Uploaded avatars: a workspace's icon and a person's picture.
2+//!
3+//! An image is checked by its bytes, not by what it claims to be: only
4+//! PNG, JPEG, WebP and GIF, which a browser shows as an image and nothing
5+//! else. SVG is refused, since it can carry script. Each is stored in the
6+//! `AVATARS` KV namespace under the SHA-256 of its bytes, with its media
7+//! type as metadata. The hash is also what the workspace or user row holds
8+//! and what the address `/avatars/<hash>` names: an address always means
9+//! the same image, so it can be cached for good. The site serves the
10+//! namespace; only this service writes to it.
11+
12+use base64::Engine;
13+use base64::engine::general_purpose::STANDARD;
14+use g1t_contracts::identity::*;
15+use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role};
16+use sha2::{Digest, Sha256};
17+use worker::wasm_bindgen::JsValue;
18+use worker::Result;
19+
20+use crate::Identity;
21+
22+/// The media type an image's bytes show it to be, or why it is refused.
23+pub fn sniff(bytes: &[u8]) -> std::result::Result<&'static str, &'static str> {
24+ if bytes.is_empty() {
25+ return Err("That file is empty.");
26+ }
27+ if bytes.len() > MAX_AVATAR_BYTES {
28+ return Err("Use an image of at most 1 MB.");
29+ }
30+ if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
31+ Ok("image/png")
32+ } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
33+ Ok("image/jpeg")
34+ } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
35+ Ok("image/gif")
36+ } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
37+ Ok("image/webp")
38+ } else {
39+ Err("Use a PNG, JPEG, WebP or GIF image.")
40+ }
41+}
42+
43+/// An upload decoded and checked: its key and media type.
44+pub struct Checked {
45+ pub key: String,
46+ pub content_type: &'static str,
47+ pub bytes: Vec<u8>,
48+}
49+
50+/// Decodes an uploaded image and checks it.
51+pub fn check(image: &str) -> std::result::Result<Checked, &'static str> {
52+ // Base64 is a third longer; anything far past the limit is refused
53+ // before it is decoded.
54+ if image.len() > MAX_AVATAR_BYTES / 3 * 4 + 8 {
55+ return Err("Use an image of at most 1 MB.");
56+ }
57+ let bytes = STANDARD
58+ .decode(image.trim())
59+ .map_err(|_| "That upload could not be read.")?;
60+ let content_type = sniff(&bytes)?;
61+ Ok(Checked {
62+ key: hex::encode(Sha256::digest(&bytes)),
63+ content_type,
64+ bytes,
65+ })
66+}
67+
68+/// Whether a stored avatar key is well formed: 64 lowercase hex digits.
69+pub fn is_key(key: &str) -> bool {
70+ key.len() == 64 && key.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
71+}
72+
73+/// What is kept beside an avatar's bytes, as the site reads it.
74+#[derive(serde::Serialize)]
75+#[serde(rename_all = "camelCase")]
76+struct Stored {
77+ content_type: &'static str,
78+}
79+
80+impl Identity {
81+ /// Stores a checked image. The same bytes always land on the same
82+ /// key, so storing them again changes nothing.
83+ async fn store_avatar(&self, checked: Checked) -> Result<String> {
84+ self.env
85+ .kv("AVATARS")?
86+ .put_bytes(&checked.key, &checked.bytes)?
87+ .metadata(Stored {
88+ content_type: checked.content_type,
89+ })?
90+ .execute()
91+ .await?;
92+ Ok(checked.key)
93+ }
94+
95+ /// Deletes an avatar no workspace or person uses any more.
96+ async fn forget_avatar(&self, key: Option<String>) -> Result<()> {
97+ let Some(key) = key.filter(|key| is_key(key)) else {
98+ return Ok(());
99+ };
100+ let used = self
101+ .db
102+ .prepare(
103+ "SELECT 1 FROM workspaces WHERE avatar = ?1
104+ UNION ALL SELECT 1 FROM users WHERE avatar = ?1 LIMIT 1",
105+ )
106+ .bind(&[key.as_str().into()])?
107+ .first::<serde_json::Value>(None)
108+ .await?
109+ .is_some();
110+ if !used {
111+ self.env.kv("AVATARS")?.delete(&key).await?;
112+ }
113+ Ok(())
114+ }
115+
116+ /// The avatar a row has now, before it is changed.
117+ async fn current_avatar(&self, sql: &str, param: &str) -> Result<Option<String>> {
118+ #[derive(serde::Deserialize)]
119+ struct Row {
120+ avatar: Option<String>,
121+ }
122+ Ok(self
123+ .db
124+ .prepare(sql)
125+ .bind(&[param.into()])?
126+ .first::<Row>(None)
127+ .await?
128+ .and_then(|row| row.avatar))
129+ }
130+
131+ /// Checks and stores `image`, or does nothing for none; the new key.
132+ async fn upload(&self, image: Option<&str>) -> Result<std::result::Result<Option<String>, &'static str>> {
133+ let Some(image) = image else {
134+ return Ok(Ok(None));
135+ };
136+ match check(image) {
137+ Ok(checked) => Ok(Ok(Some(self.store_avatar(checked).await?))),
138+ Err(message) => Ok(Err(message)),
139+ }
140+ }
141+
142+ pub async fn set_workspace_avatar(&self, a: SetWorkspaceAvatarArgs) -> Result<Outcome<Workspace>> {
143+ let slug = a.slug.to_lowercase();
144+ // Checked here, not only by the page: only an owner, as a person.
145+ if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
146+ return Ok(Outcome::fail(
147+ FailureCode::Forbidden,
148+ "Only an owner can change a workspace's icon.",
149+ ));
150+ }
151+ let previous = self
152+ .current_avatar("SELECT avatar FROM workspaces WHERE slug = ?", &slug)
153+ .await?;
154+ let key = match self.upload(a.image.as_deref()).await? {
155+ Ok(key) => key,
156+ Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
157+ };
158+ self.db
159+ .prepare("UPDATE workspaces SET avatar = ? WHERE slug = ?")
160+ .bind(&[
161+ key.as_deref().map_or(JsValue::NULL, JsValue::from),
162+ slug.as_str().into(),
163+ ])?
164+ .run()
165+ .await?;
166+ if previous != key {
167+ self.forget_avatar(previous).await?;
168+ }
169+ Ok(match self.get_workspace(SlugArgs { slug }).await? {
170+ Some(workspace) => Outcome::Ok(workspace),
171+ None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
172+ })
173+ }
174+
175+ pub async fn set_user_avatar(&self, a: SetUserAvatarArgs) -> Result<Outcome<Option<String>>> {
176+ if a.user.kind != PrincipalKind::User || a.user.id.is_empty() {
177+ return Ok(Outcome::fail(
178+ FailureCode::Forbidden,
179+ "Only a person can change their own picture.",
180+ ));
181+ }
182+ let previous = self
183+ .current_avatar("SELECT avatar FROM users WHERE id = ?", &a.user.id)
184+ .await?;
185+ let key = match self.upload(a.image.as_deref()).await? {
186+ Ok(key) => key,
187+ Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
188+ };
189+ self.db
190+ .prepare("UPDATE users SET avatar = ? WHERE id = ?")
191+ .bind(&[
192+ key.as_deref().map_or(JsValue::NULL, JsValue::from),
193+ a.user.id.as_str().into(),
194+ ])?
195+ .run()
196+ .await?;
197+ if previous != key {
198+ self.forget_avatar(previous).await?;
199+ }
200+ Ok(Outcome::Ok(key))
201+ }
202+}
203+
204+#[cfg(test)]
205+mod tests {
206+ use super::*;
207+
208+ const PNG: &[u8] = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR";
209+ const JPEG: &[u8] = &[0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10, b'J', b'F', b'I', b'F'];
210+ const WEBP: &[u8] = b"RIFF\x24\0\0\0WEBPVP8 ";
211+ const GIF: &[u8] = b"GIF89a\x01\0\x01\0";
212+
213+ #[test]
214+ fn knows_each_image_by_its_bytes() {
215+ assert_eq!(sniff(PNG), Ok("image/png"));
216+ assert_eq!(sniff(JPEG), Ok("image/jpeg"));
217+ assert_eq!(sniff(WEBP), Ok("image/webp"));
218+ assert_eq!(sniff(GIF), Ok("image/gif"));
219+ assert_eq!(sniff(b"GIF87a\x01\0"), Ok("image/gif"));
220+ }
221+
222+ #[test]
223+ fn refuses_svg_and_anything_else() {
224+ assert!(sniff(b"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>").is_err());
225+ assert!(sniff(b"<?xml version=\"1.0\"?><svg/>").is_err());
226+ assert!(sniff(b"<html><body>hi</body></html>").is_err());
227+ assert!(sniff(b"RIFF\0\0\0\0WAVEfmt ").is_err());
228+ assert!(sniff(b"\x89PNX").is_err());
229+ assert!(sniff(b"").is_err());
230+ }
231+
232+ #[test]
233+ fn refuses_more_than_a_megabyte() {
234+ let mut big = PNG.to_vec();
235+ big.resize(MAX_AVATAR_BYTES, 0);
236+ assert_eq!(sniff(&big), Ok("image/png"));
237+ big.push(0);
238+ assert!(sniff(&big).is_err());
239+ assert!(check(&STANDARD.encode(&big)).is_err());
240+ }
241+
242+ #[test]
243+ fn keys_an_image_by_its_hash() {
244+ let checked = check(&STANDARD.encode(PNG)).ok().unwrap();
245+ assert_eq!(checked.content_type, "image/png");
246+ assert_eq!(checked.key, hex::encode(Sha256::digest(PNG)));
247+ assert!(is_key(&checked.key));
248+ assert!(!is_key("../etc/passwd"));
249+ assert!(!is_key(&checked.key.to_uppercase()));
250+ }
251+
252+ #[test]
253+ fn refuses_what_is_not_base64() {
254+ assert!(check("not base64!").is_err());
255+ }
256+}
+9−1
44 //! the methods and their arguments.
55
66 mod admin;
7+mod avatars;
78 mod crypto;
89 mod device;
910 mod email;
3233 id: String,
3334 username: String,
3435 verified: u8,
36+ /// Selected only where the person is being shown to themselves.
37+ #[serde(default)]
38+ avatar: Option<String>,
3539 }
3640
3741 impl From<Account> for User {
4044 id: row.id,
4145 username: row.username,
4246 verified: row.verified != 0,
47+ avatar: row.avatar,
4348 ..User::default()
4449 }
4550 }
389394 async fn user_for_session(&self, a: SessionArgs) -> Result<Viewer> {
390395 self.find_user(
391396 &format!(
392− "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified
397+ "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified,
398+ users.avatar
393399 FROM sessions JOIN users ON users.id = sessions.user_id
394400 WHERE sessions.id = ? AND sessions.expires_at > {SQL_NOW}"
395401 ),
540546 "add_member" => reply(&identity.add_member(args(body)?).await?),
541547 "remove_member" => reply(&identity.remove_member(args(body)?).await?),
542548 "update_workspace" => reply(&identity.update_workspace(args(body)?).await?),
549+ "set_workspace_avatar" => reply(&identity.set_workspace_avatar(args(body)?).await?),
550+ "set_user_avatar" => reply(&identity.set_user_avatar(args(body)?).await?),
543551 "list_workspace_tokens" => reply(&identity.list_workspace_tokens(args(body)?).await?),
544552 "create_workspace_token" => reply(&identity.create_workspace_token(args(body)?).await?),
545553 "remove_workspace_token" => reply(&identity.remove_workspace_token(args(body)?).await?),
+4−8
105105 username: AGENT_NAME.to_owned(),
106106 kind: PrincipalKind::Agent,
107107 verified: true,
108− workspaces: vec![Membership {
109− slug: scope.repo.namespace,
110− role: Role::Member,
111− }],
108+ workspaces: vec![Membership::member(scope.repo.namespace)],
109+ ..User::default()
112110 }));
113111 }
114112 let viewer = match (&presented.user_id, &presented.workspace_id) {
143141 username: workspace.slug.clone(),
144142 kind: PrincipalKind::Workspace,
145143 verified: true,
146− workspaces: vec![Membership {
147− slug: workspace.slug,
148− role: Role::Member,
149− }],
144+ workspaces: vec![Membership::member(workspace.slug)],
145+ ..User::default()
150146 }))
151147 }
152148
+8−3
2323 const MAX_DESCRIPTION_LENGTH: usize = 160;
2424
2525 const WORKSPACE_COLUMNS: &str = "workspaces.id, workspaces.slug, workspaces.name,
26− workspaces.description, workspaces.created_at,
26+ workspaces.description, workspaces.avatar, workspaces.created_at,
2727 (SELECT count(*) FROM workspace_members
2828 WHERE workspace_members.workspace_id = workspaces.id) AS member_count";
2929
3333 slug: String,
3434 name: String,
3535 description: Option<String>,
36+ avatar: Option<String>,
3637 created_at: String,
3738 member_count: u32,
3839 }
4647 description: row.description,
4748 created_at: row.created_at,
4849 member_count: row.member_count,
50+ avatar: row.avatar,
4951 }
5052 }
5153 }
5860
5961 impl Identity {
6062 /// The workspaces a user belongs to, attached to every user resolved
61− /// from credentials.
63+ /// from credentials, with what the site needs to show each one.
6264 pub async fn memberships(&self, user_id: &str) -> Result<Vec<Membership>> {
6365 self.db
6466 .prepare(
65− "SELECT workspaces.slug, workspace_members.role FROM workspace_members
67+ "SELECT workspaces.slug, workspace_members.role, workspaces.name,
68+ workspaces.avatar
69+ FROM workspace_members
6670 JOIN workspaces ON workspaces.id = workspace_members.workspace_id
6771 WHERE workspace_members.user_id = ? ORDER BY workspaces.slug",
6872 )
129133 slug,
130134 created_at: rfc3339(now),
131135 member_count: 1,
136+ avatar: None,
132137 };
133138 self.db
134139 .batch(vec![
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.