Skip to content
480 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to start1/**
2 * A project's or a workspace's secrets and variables, as one list in the
3 * way Vercel lists environment variables: each row is a key, its type
4 * (Secret or Config), the environments it applies to and who reads it.
5 * Adding and editing happen in a side panel, opened by `?add` or
6 * `?edit=<id>` so the page works without scripts.
7 */
8import { Lock, Pencil, Plus, Search, SlidersHorizontal, Trash2, X } from "lucide-react";
9import { useMemo, useState } from "react";
Merge branch 'worktree-agent-a985c01f43538af01'10import { Form, Link, useLocation } from "react-router";
Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to start11
12import type { Setting } from "@g1t/contracts";
13
14import type { SecretsAction, SecretsData } from "../lib/secrets.server";
Merge branch 'worktree-agent-a985c01f43538af01'15import { ButtonLink, EmptyState, ErrorText, SubmitButton, TimeAgo } from "./ui";
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.16import { Badge } from "./ui/badge";
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.17import { Card } from "./ui/card";
Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to start18import { CheckboxOption } from "./ui/checkbox";
Merge branch 'worktree-agent-ae1299e92e4462012'19import { Hint } from "./ui/hint";
Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to start20import { RadioCard, RadioGroup, RadioOption } from "./ui/radio-group";
21import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select";
22
23/** The environments every deployment knows; workflow jobs may name others. */
24const KNOWN_ENVIRONMENTS = ["production", "preview"];
25
26const READERS: Record<string, string> = { workflows: "Workflows", deployments: "Deployments" };
27
28function environmentsLabel(environments: string[]): string {
29 if (environments.length === 0) return "All environments";
30 return environments.map((env) => env.charAt(0).toUpperCase() + env.slice(1)).join(", ");
31}
32
33const SELECT =
34 "rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors hover:border-line-strong focus:border-accent-dim";
35
36export function SecretsPanel({
37 data,
38 action,
39 scope,
40 manage,
41}: {
42 data: SecretsData;
43 action: SecretsAction | undefined;
44 scope: "project" | "workspace";
45 manage: boolean;
46}) {
47 const location = useLocation();
48 const params = new URLSearchParams(location.search);
49 const editing = params.get("edit");
50 const adding = params.has("add");
51 const row = editing ? data.rows.find((r) => r.id === editing && r.scope === scope) : undefined;
52 const [query, setQuery] = useState("");
53 const [type, setType] = useState("all");
54 const [environment, setEnvironment] = useState("all");
55 const environments = useMemo(
56 () => [...new Set([...KNOWN_ENVIRONMENTS, ...data.rows.flatMap((r) => r.environments)])],
57 [data.rows],
58 );
59 const shown = data.rows.filter(
60 (r) =>
61 (!query || r.name.toLowerCase().includes(query.toLowerCase()) || r.note?.toLowerCase().includes(query.toLowerCase())) &&
62 (type === "all" || r.kind === type) &&
63 (environment === "all" || r.environments.length === 0 || r.environments.includes(environment)),
64 );
65
66 return (
67 <div className="max-w-5xl">
68 <header className="flex flex-wrap items-start justify-between gap-4">
69 <div>
70 <h2 className="text-lg font-semibold tracking-tight">Secrets and variables</h2>
71 <p className="mt-1 max-w-2xl text-sm text-muted">
72 One list for everything that reads them. Each row says which environments it applies to and whether{" "}
73 <strong className="font-medium text-fg">workflows</strong> (as <code className="text-fg">secrets.KEY</code>{" "}
74 and <code className="text-fg">vars.KEY</code>), <strong className="font-medium text-fg">deployments</strong>{" "}
75 (the build's environment and the running app's <code className="text-fg">env.KEY</code>), or both read it.
76 {scope === "workspace"
77 ? " Every project, or the ones you link, reads the workspace's; a project's own row of the same key wins."
78 : " Rows from the workspace are shown too; adding the same key here replaces them for this project."}{" "}
79 <a href="https://docs.g1t.sh/guides/secrets-and-variables/" className="text-fg hover:underline">
80 How they are read
81 </a>
82 </p>
83 </div>
84 {manage && (
85 <ButtonLink to="?add" variant="accent">
86 <Plus size={14} />
87 Add
88 </ButtonLink>
89 )}
90 </header>
91
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.92 <Card asChild radius="lg" className="mt-4 px-4 py-2.5 text-xs text-muted">
93 <p>
94 Built in: workflows get <code className="text-fg">secrets.G1T_TOKEN</code>, the workspace's own token for
95 the run, with <code className="text-fg">secrets.GITHUB_TOKEN</code> as its alias. Agents
96 and the merge queue never read secrets or variables, and runs for people outside the workspace get no secrets.
97 </p>
98 </Card>
Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to start99
100 <div className="mt-5 flex flex-wrap gap-2">
101 <label className="relative min-w-56 grow">
102 <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
103 <input
104 value={query}
105 onChange={(e) => setQuery(e.target.value)}
106 placeholder="Search keys and notes"
107 aria-label="Search"
108 className={`${SELECT} w-full pl-9`}
109 />
110 </label>
111 <Select value={type} onValueChange={setType}>
112 <SelectTrigger aria-label="Type" className="h-auto w-auto py-2">
113 <SelectValue />
114 </SelectTrigger>
115 <SelectContent align="end">
116 <SelectItem value="all">All types</SelectItem>
117 <SelectSeparator />
118 <SelectItem value="secret" icon={<Lock />}>Secret</SelectItem>
119 <SelectItem value="variable" icon={<SlidersHorizontal />}>Config</SelectItem>
120 </SelectContent>
121 </Select>
122 <Select value={environment} onValueChange={setEnvironment}>
123 <SelectTrigger aria-label="Environment" className="h-auto w-auto py-2">
124 <SelectValue />
125 </SelectTrigger>
126 <SelectContent align="end">
127 <SelectItem value="all">All environments</SelectItem>
128 {environments.length > 0 && <SelectSeparator />}
129 {environments.map((env) => (
130 <SelectItem key={env} value={env}>
131 {environmentsLabel([env])}
132 </SelectItem>
133 ))}
134 </SelectContent>
135 </Select>
136 </div>
137
138 <ErrorText>{data.error}</ErrorText>
139 {!editing && !adding && <div className="mt-2"><ErrorText>{action?.error}</ErrorText></div>}
140
141 <div className="mt-4">
142 {data.rows.length === 0 ? (
143 <EmptyState title="No secrets or variables yet">
144 Add one, or paste a <code>.env</code> file into Add to bring many at once.
145 </EmptyState>
146 ) : shown.length === 0 ? (
147 <EmptyState title="Nothing matches" />
148 ) : (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.149 <Card asChild className="overflow-hidden">
150 <ul>
151 {shown.map((r) => (
152 <Row key={r.id} row={r} inherited={r.scope !== scope} manage={manage} />
153 ))}
154 </ul>
155 </Card>
Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to start156 )}
157 </div>
158
159 {manage && (adding || row) && (
Merge branch 'worktree-agent-a985c01f43538af01'160 // Keyed to the row, so going from one row's edit to another's starts from that row.
161 <Drawer key={row?.id ?? "add"} row={row} scope={scope} projects={data.projects} error={action?.error} />
Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to start162 )}
163 </div>
164 );
165}
166
167function Row({ row, inherited, manage }: { row: Setting; inherited: boolean; manage: boolean }) {
168 const secret = row.kind === "secret";
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.169 const hosts = row.brokeredHosts ?? [];
Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to start170 return (
171 <li className="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_auto] items-center gap-x-4 gap-y-1 border-t border-line px-4 py-3 text-sm first:border-t-0 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_minmax(0,1fr)_6rem_6rem_auto]">
172 <div className="min-w-0">
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.173 <div className="flex min-w-0 items-center gap-2">
174 <p className="truncate font-mono text-[0.8125rem]">{row.name}</p>
175 {secret && hosts.length > 0 && (
176 <Hint label={`Filled in for ${hosts.join(", ")}`}>
177 <Badge tone="accent">
178 Brokered
179 <span className="sr-only">: filled in for {hosts.join(", ")}</span>
180 </Badge>
181 </Hint>
182 )}
183 </div>
Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to start184 {row.note && <p className="truncate text-xs text-faint">{row.note}</p>}
185 {!secret && row.value != null && <p className="truncate font-mono text-xs text-muted">{row.value}</p>}
186 </div>
187 <span className="truncate text-muted">{environmentsLabel(row.environments)}</span>
188 <span className="hidden truncate text-xs text-muted md:block">
189 {row.availableTo.map((r) => READERS[r] ?? r).join(" · ")}
190 </span>
191 <span className="hidden items-center gap-1.5 text-xs text-muted md:flex">
192 {secret ? <Lock size={13} /> : <SlidersHorizontal size={13} />}
193 {secret ? "Secret" : "Config"}
194 </span>
195 <span className="hidden text-xs text-faint md:block">
196 <TimeAgo at={row.updatedAt} />
197 </span>
198 <span className="flex items-center justify-end gap-1">
199 {inherited ? (
200 <span className="rounded-full px-2 py-px text-xs text-muted ring-1 ring-line">Workspace</span>
201 ) : (
202 <>
203 {row.projects.length > 0 && (
Merge branch 'worktree-agent-ae1299e92e4462012'204 <Hint label={row.projects.join(", ")}>
205 <span className="mr-1 text-xs text-faint">
206 {row.projects.length} {row.projects.length === 1 ? "project" : "projects"}
207 <span className="sr-only">: {row.projects.join(", ")}</span>
208 </span>
209 </Hint>
Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to start210 )}
211 {manage && (
212 <>
213 <Link
214 to={`?edit=${row.id}`}
215 aria-label={`Edit ${row.name}`}
Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar216 className="rounded-md p-1.5 text-faint max-sm:p-2.5 transition-colors hover:bg-raised hover:text-fg"
Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to start217 >
218 <Pencil size={14} />
219 </Link>
220 <Form method="post">
221 <input type="hidden" name="intent" value="delete" />
222 <input type="hidden" name="id" value={row.id} />
223 <input type="hidden" name="name" value={row.name} />
Merge branch 'worktree-agent-a985c01f43538af01'224 <SubmitButton
225 icon
226 match={{ intent: "delete", id: row.id }}
Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to start227 aria-label={`Remove ${row.name}`}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.228 variant="ghost"
229 size="inline"
230 className="p-1.5 text-faint max-sm:p-2.5 hover:text-danger"
Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to start231 >
232 <Trash2 size={14} />
Merge branch 'worktree-agent-a985c01f43538af01'233 </SubmitButton>
Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to start234 </Form>
235 </>
236 )}
237 </>
238 )}
239 </span>
240 </li>
241 );
242}
243
244function Drawer({
245 row,
246 scope,
247 projects,
248 error,
249}: {
250 row: Setting | undefined;
251 scope: "project" | "workspace";
252 projects: string[];
253 error: string | undefined;
254}) {
255 const editing = !!row;
256 const [type, setType] = useState<"secret" | "config">(row?.kind === "variable" ? "config" : "secret");
257 const [some, setSome] = useState(!!row && row.environments.length > 0);
258 const [reach, setReach] = useState(row && row.projects.length > 0 ? "some" : "all");
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.259 const [brokered, setBrokered] = useState((row?.brokeredHosts?.length ?? 0) > 0);
260 // Only a secret is brokered, and only workflows read one.
261 const brokering = type === "secret" && brokered;
Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to start262 const custom = row?.environments.filter((env) => !KNOWN_ENVIRONMENTS.includes(env)) ?? [];
263 const field =
264 "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim";
265 return (
266 <div className="fixed inset-0 z-50 flex justify-end bg-black/50" role="dialog" aria-modal="true" aria-label={editing ? "Edit" : "Add"}>
267 <Link to="?" aria-label="Close" className="grow" />
Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar268 <Form method="post" className="flex h-full w-full max-w-xl flex-col border-l border-line bg-bg pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] shadow-2xl">
Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to start269 <div className="flex items-center justify-between border-b border-line px-6 py-4">
270 <h3 className="font-semibold">{editing ? `Edit ${row.name}` : "Add a secret or variable"}</h3>
Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar271 <Link to="?" aria-label="Close" className="flex items-center justify-center rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg max-sm:size-10">
Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to start272 <X size={16} />
273 </Link>
274 </div>
275 <div className="grow space-y-6 overflow-y-auto px-6 py-5">
276 <input type="hidden" name="intent" value="save" />
277 {row && <input type="hidden" name="id" value={row.id} />}
278
279 <fieldset>
280 <legend className="mb-2 text-sm font-medium text-muted">Type</legend>
281 <RadioGroup
282 name="type"
283 value={type}
284 onValueChange={(value) => setType(value as typeof type)}
285 aria-label="Type"
286 className="gap-3 sm:grid-cols-2"
287 >
288 {(
289 [
290 ["secret", "Secret", "You can't read it again after saving. For passwords, API keys and tokens.", <Lock key="i" />],
291 ["config", "Config", "Readable by members after saving. For values that are not sensitive.", <SlidersHorizontal key="i" />],
292 ] as const
293 ).map(([value, title, text, icon]) => (
294 // A secret's value is sealed: it can never become config.
295 <RadioCard
296 key={value}
297 value={value}
298 title={title}
299 description={text}
300 icon={icon}
301 disabled={value === "config" && row?.kind === "secret"}
302 />
303 ))}
304 </RadioGroup>
305 {row?.kind === "variable" && (
306 <p className="mt-2 text-xs text-faint">Config can become a secret; a secret cannot become config.</p>
307 )}
308 </fieldset>
309
310 <label className="block">
311 <span className="mb-1.5 block text-sm font-medium text-muted">Key</span>
312 {editing ? (
313 <input name="key" value={row.name} readOnly className={`${field} font-mono text-muted`} />
314 ) : (
315 <textarea
316 name="key"
317 required
318 rows={1}
319 placeholder="CLIENT_KEY, or paste a .env file"
320 autoComplete="off"
321 spellCheck={false}
322 className={`${field} min-h-10 font-mono`}
323 />
324 )}
325 </label>
326
327 <label className="block">
328 <span className="mb-1.5 block text-sm font-medium text-muted">Value</span>
329 <textarea
330 name="value"
331 rows={type === "secret" ? 3 : 2}
332 defaultValue={row?.kind === "variable" ? (row.value ?? "") : ""}
333 placeholder={
334 editing && row.kind === "secret" ? "Leave empty to keep the current value" : "Enter a value"
335 }
336 autoComplete="off"
337 spellCheck={false}
338 className={`${field} font-mono`}
339 />
340 </label>
341
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.342 {type === "secret" && (
343 <fieldset>
344 <legend className="mb-2 text-sm font-medium text-muted">Brokered</legend>
345 <div className="space-y-2 text-sm">
346 <CheckboxOption
347 name="brokered"
348 checked={brokered}
349 onCheckedChange={(checked) => setBrokered(checked === true)}
350 label="Only for these hosts"
351 description="The job gets a stand-in; g1t puts the real value on requests to these hosts, so it never enters the sandbox."
352 />
353 {brokered && (
354 <div className="ml-6 space-y-2">
355 <textarea
356 name="brokeredHosts"
357 rows={2}
358 required
359 defaultValue={row?.brokeredHosts?.join("\n") ?? ""}
360 placeholder={"api.example.com\n*.example.com"}
361 autoComplete="off"
362 spellCheck={false}
363 aria-label="Hosts"
364 className={`${field} font-mono`}
365 />
366 <p className="text-xs text-faint">
367 One per line. Filled in in headers and the query string, for workflow jobs on g1t's machines.{" "}
368 <a href="https://docs.g1t.sh/guides/secrets-and-variables/#brokered-secrets" className="text-fg hover:underline">
369 What it covers
370 </a>
371 </p>
372 </div>
373 )}
374 </div>
375 </fieldset>
376 )}
377
Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to start378 <label className="block">
379 <span className="mb-1.5 block text-sm font-medium text-muted">Note (optional)</span>
380 <input name="note" defaultValue={row?.note ?? ""} placeholder="Where to rotate it, or who to ask" className={field} />
381 </label>
382
383 <fieldset>
384 <legend className="mb-2 text-sm font-medium text-muted">Environments</legend>
385 <div className="space-y-2 text-sm">
386 <RadioGroup name="scope" value={some ? "some" : "all"} onValueChange={(value) => setSome(value === "some")} aria-label="Environments">
387 <RadioOption value="all" label="All environments" />
388 <RadioOption value="some" label="Only some" />
389 </RadioGroup>
390 {some && (
391 <div className="ml-6 space-y-2">
392 {KNOWN_ENVIRONMENTS.map((env) => (
393 <CheckboxOption
394 key={env}
395 name="env"
396 value={env}
397 defaultChecked={row?.environments.includes(env)}
398 label={environmentsLabel([env])}
399 />
400 ))}
401 <input
402 name="envCustom"
403 defaultValue={custom.join(", ")}
404 placeholder="Others, comma-separated: staging, qa"
405 className={field}
406 />
407 <p className="text-xs text-faint">
408 Deployments are production and preview; a workflow job reads the row for its{" "}
409 <code>environment:</code>, and rows for all environments otherwise.
410 </p>
411 </div>
412 )}
413 </div>
414 </fieldset>
415
416 <fieldset>
417 <legend className="mb-2 text-sm font-medium text-muted">Available to</legend>
418 <div className="space-y-2 text-sm">
419 {(
420 [
421 ["workflows", "Workflows", "secrets.KEY or vars.KEY in GitHub Actions workflows"],
422 ["deployments", "Deployments", "The build's environment, and env.KEY in the running app"],
423 ] as const
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.424 ).map(([value, title, text]) => {
425 // A deployment's app needs the value itself, so a brokered
426 // secret is never one of its.
427 const closed = brokering && value === "deployments";
428 return (
429 <CheckboxOption
430 key={`${value}${closed ? "-closed" : ""}`}
431 name="availableTo"
432 value={value}
433 defaultChecked={closed ? false : row ? row.availableTo.includes(value) : true}
434 disabled={closed}
435 label={title}
436 description={closed ? "Not for a brokered secret: a deployment's app needs the value itself." : text}
437 />
438 );
439 })}
Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to start440 </div>
441 </fieldset>
442
443 {scope === "workspace" && (
444 <fieldset>
445 <legend className="mb-2 text-sm font-medium text-muted">Projects</legend>
446 <div className="space-y-2 text-sm">
447 <RadioGroup name="reach" value={reach} onValueChange={(value) => setReach(value as typeof reach)} aria-label="Projects">
448 <RadioOption value="all" label="Every project" />
449 <RadioOption value="some" label="Only these" />
450 </RadioGroup>
451 {reach === "some" && (
452 <div className="ml-6 grid max-h-48 gap-1.5 overflow-y-auto p-0.5 sm:grid-cols-2">
453 {projects.map((name) => (
454 <CheckboxOption
455 key={name}
456 name="project"
457 value={name}
458 defaultChecked={row?.projects.includes(name)}
459 label={name}
460 className="items-center"
461 labelClassName="font-mono text-xs"
462 />
463 ))}
464 </div>
465 )}
466 </div>
467 </fieldset>
468 )}
469 <ErrorText>{error}</ErrorText>
470 </div>
471 <div className="flex items-center justify-between gap-4 border-t border-line px-6 py-4">
472 <p className="text-xs text-faint">{editing ? "" : "Paste .env contents into Key to add many."}</p>
Merge branch 'worktree-agent-a985c01f43538af01'473 <SubmitButton match={{ intent: "save" }} pending="Saving…">
Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to start474 Save
Merge branch 'worktree-agent-a985c01f43538af01'475 </SubmitButton>
Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to start476 </div>
477 </Form>
478 </div>
479 );
480}

This file's history is long; its oldest lines are credited to the oldest commit read.