@spunto/design-system 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -3
- package/package.json +9 -1
- package/src/components/devcontainer/catalog-card.tsx +269 -0
- package/src/components/devcontainer/catalog-tile.tsx +133 -0
- package/src/components/devcontainer/extension-card.tsx +146 -0
- package/src/components/devcontainer/feature-card.tsx +157 -0
- package/src/components/devcontainer/feature-tile.tsx +63 -0
- package/src/components/devcontainer/image-card.tsx +107 -0
- package/src/components/devcontainer/image-tile.tsx +56 -0
- package/src/components/devcontainer/index.ts +68 -0
- package/src/components/devcontainer/publisher-badge.tsx +91 -0
- package/src/components/devcontainer/registry-ref.tsx +89 -0
- package/src/components/devcontainer/registry.ts +154 -0
- package/src/components/devcontainer/stack-mark-paths.ts +60 -0
- package/src/components/devcontainer/stack-mark.tsx +92 -0
- package/src/components/devcontainer/types.ts +84 -0
- package/src/components/projects/advanced-sections.tsx +136 -0
- package/src/components/projects/build-manifest.tsx +116 -0
- package/src/components/projects/extension-picker.tsx +169 -0
- package/src/components/projects/feature-picker.tsx +193 -0
- package/src/components/projects/form-section.tsx +97 -0
- package/src/components/projects/image-picker.tsx +206 -0
- package/src/components/projects/index.ts +58 -0
- package/src/components/projects/project-form.tsx +488 -0
- package/src/components/projects/repo-list.tsx +302 -0
- package/src/components/projects/secret-list.tsx +159 -0
- package/src/components/projects/types.ts +136 -0
- package/styles.css +13 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState, type ComponentType, type ReactNode } from "react"
|
|
4
|
+
import {
|
|
5
|
+
BlocksIcon,
|
|
6
|
+
BoxIcon,
|
|
7
|
+
GitBranchIcon,
|
|
8
|
+
KeyRoundIcon,
|
|
9
|
+
LayersIcon,
|
|
10
|
+
NetworkIcon,
|
|
11
|
+
PuzzleIcon,
|
|
12
|
+
SquarePenIcon,
|
|
13
|
+
TerminalIcon,
|
|
14
|
+
} from "lucide-react"
|
|
15
|
+
|
|
16
|
+
import { cn } from "../../utils"
|
|
17
|
+
import { Input } from "../input"
|
|
18
|
+
import { Switch } from "../switch"
|
|
19
|
+
import { Textarea } from "../textarea"
|
|
20
|
+
import { ExtensionPicker } from "./extension-picker"
|
|
21
|
+
import { FeaturePicker, type FeaturePickerProps } from "./feature-picker"
|
|
22
|
+
import { ImagePicker, type ImagePickerProps } from "./image-picker"
|
|
23
|
+
import { AdvancedDisclosure, advancedSummary } from "./advanced-sections"
|
|
24
|
+
import { BuildManifest, type ManifestRow } from "./build-manifest"
|
|
25
|
+
import { Field, FormSection, type SectionAccent } from "./form-section"
|
|
26
|
+
import { RepoList, type GithubConnection, type GithubRepoOption, type RepoFieldRender } from "./repo-list"
|
|
27
|
+
import { SecretList } from "./secret-list"
|
|
28
|
+
import {
|
|
29
|
+
CREATE_SECTIONS,
|
|
30
|
+
type ProjectFormSectionId,
|
|
31
|
+
type ProjectFormValue,
|
|
32
|
+
type ProjectSecretRef,
|
|
33
|
+
} from "./types"
|
|
34
|
+
import type { DevcontainerFeatureEntry, DevcontainerImageEntry, VscodeExtensionEntry } from "../devcontainer/types"
|
|
35
|
+
|
|
36
|
+
/** Chrome of each section: number, icon, accent, wording. */
|
|
37
|
+
const SECTION_META: Record<
|
|
38
|
+
ProjectFormSectionId,
|
|
39
|
+
{ icon: ComponentType<{ className?: string }>; accent: SectionAccent; title: string; hint?: string }
|
|
40
|
+
> = {
|
|
41
|
+
identity: { icon: SquarePenIcon, accent: "build", title: "Identity", hint: "Name your project and what it's for" },
|
|
42
|
+
image: { icon: BoxIcon, accent: "ship", title: "Base image", hint: "The devcontainer your workers run on" },
|
|
43
|
+
repositories: { icon: GitBranchIcon, accent: "run", title: "Repositories", hint: "Cloned into /workspace at startup" },
|
|
44
|
+
features: { icon: PuzzleIcon, accent: "flame", title: "Features", hint: "Devcontainer features baked into the image" },
|
|
45
|
+
extensions: { icon: BlocksIcon, accent: "build", title: "Extensions", hint: "VS Code extensions, installed via code-server" },
|
|
46
|
+
lifecycle: { icon: TerminalIcon, accent: "ship", title: "Lifecycle", hint: "Commands run on create & on every start" },
|
|
47
|
+
ports: { icon: NetworkIcon, accent: "run", title: "Ports", hint: "Automatically forwarded for every worker" },
|
|
48
|
+
prewarm: { icon: LayersIcon, accent: "build", title: "Prewarm images", hint: "Docker images pre-loaded into the worker's docker-in-docker on first start" },
|
|
49
|
+
docker: { icon: BoxIcon, accent: "build", title: "Docker-in-Docker", hint: "Run workers privileged with a Docker daemon inside" },
|
|
50
|
+
secrets: { icon: KeyRoundIcon, accent: "flame", title: "Secrets", hint: "Env vars injected into every workspace" },
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Folded by default: everything past identity / base image / repositories.
|
|
55
|
+
* Those three decide what a worker *is*; the rest is tuning, and putting ten
|
|
56
|
+
* sections in front of someone creating their first project is how a form stops
|
|
57
|
+
* being filled in at all.
|
|
58
|
+
*/
|
|
59
|
+
const DEFAULT_ADVANCED: ProjectFormSectionId[] = [
|
|
60
|
+
"features",
|
|
61
|
+
"extensions",
|
|
62
|
+
"lifecycle",
|
|
63
|
+
"ports",
|
|
64
|
+
"prewarm",
|
|
65
|
+
"docker",
|
|
66
|
+
"secrets",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
export interface ProjectFormProps {
|
|
70
|
+
value: ProjectFormValue
|
|
71
|
+
onChange: (value: ProjectFormValue) => void
|
|
72
|
+
/** Receives the current value. The app maps it to its own payload and calls its API. */
|
|
73
|
+
onSubmit?: (value: ProjectFormValue) => void
|
|
74
|
+
|
|
75
|
+
/** Which sections to show, in order. Defaults to the seven creation ones. */
|
|
76
|
+
sections?: ProjectFormSectionId[]
|
|
77
|
+
/**
|
|
78
|
+
* Sections folded behind "Advanced options". Defaults to everything past
|
|
79
|
+
* identity / base image / repositories — the three that actually decide what
|
|
80
|
+
* a worker is. Pass `[]` to show the whole form flat.
|
|
81
|
+
*
|
|
82
|
+
* Safe to fold *because* the disclosure names what it holds while closed and
|
|
83
|
+
* the form opens it on its own when any of these already has a value: editing
|
|
84
|
+
* a project never hides settings you set earlier behind a shut drawer.
|
|
85
|
+
*/
|
|
86
|
+
advancedSections?: ProjectFormSectionId[]
|
|
87
|
+
advancedLabel?: string
|
|
88
|
+
advancedHint?: string
|
|
89
|
+
/** Forces the initial state. Omitted → open iff an advanced section has a value. */
|
|
90
|
+
defaultAdvancedOpen?: boolean
|
|
91
|
+
|
|
92
|
+
/** Catalogs, already fetched by the app — the form never calls an API. */
|
|
93
|
+
images?: DevcontainerImageEntry[]
|
|
94
|
+
features?: DevcontainerFeatureEntry[]
|
|
95
|
+
/**
|
|
96
|
+
* How the image catalog is laid out. Passed straight through, because the
|
|
97
|
+
* right answer depends on the page around the form: a screen that already
|
|
98
|
+
* spends 360 px on the manifest leaves the picker too narrow for the detail
|
|
99
|
+
* card to sit beside the chips, and `auto` will stack it. Force `side` if you
|
|
100
|
+
* want the panel there regardless.
|
|
101
|
+
*/
|
|
102
|
+
imagePicker?: Pick<ImagePickerProps, "layout" | "detail">
|
|
103
|
+
/** Same passthrough for the feature catalog. */
|
|
104
|
+
featurePicker?: Pick<FeaturePickerProps, "layout" | "detail">
|
|
105
|
+
|
|
106
|
+
githubRepos?: GithubRepoOption[]
|
|
107
|
+
github?: GithubConnection
|
|
108
|
+
connectUrl?: string
|
|
109
|
+
/** App-level combobox for the repo field; without it, a plain input + datalist. */
|
|
110
|
+
renderRepoField?: RepoFieldRender
|
|
111
|
+
githubIcon?: ReactNode
|
|
112
|
+
|
|
113
|
+
/** Searches Open VSX (or any marketplace). Absent → extensions are typed by hand. */
|
|
114
|
+
onSearchExtensions?: (query: string) => Promise<VscodeExtensionEntry[]>
|
|
115
|
+
|
|
116
|
+
/** Secrets that already exist server-side (edit mode). */
|
|
117
|
+
savedSecrets?: ProjectSecretRef[]
|
|
118
|
+
|
|
119
|
+
/** Extra content appended inside a section — the deploy key card, a warning… */
|
|
120
|
+
extras?: Partial<Record<ProjectFormSectionId, ReactNode>>
|
|
121
|
+
|
|
122
|
+
/** `false` drops the recap panel entirely and gives the form the full width. */
|
|
123
|
+
manifest?: boolean
|
|
124
|
+
submitLabel?: ReactNode
|
|
125
|
+
submittingLabel?: ReactNode
|
|
126
|
+
submitting?: boolean
|
|
127
|
+
error?: string | null
|
|
128
|
+
manifestCaption?: ReactNode
|
|
129
|
+
manifestFooter?: ReactNode
|
|
130
|
+
manifestTitle?: string
|
|
131
|
+
|
|
132
|
+
className?: string
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The whole "compose a dev environment" form: identity, base image, repos,
|
|
137
|
+
* features, extensions, lifecycle, ports, prewarm, docker-in-docker, secrets —
|
|
138
|
+
* plus the live build manifest that recaps it and carries the submit button.
|
|
139
|
+
*
|
|
140
|
+
* **Controlled, single value.** One `ProjectFormValue` in, one `onChange` out.
|
|
141
|
+
* The dashboard used to spread this across seven pieces of local state, three
|
|
142
|
+
* hidden inputs carrying JSON, and a `FormData` read at submit — which meant the
|
|
143
|
+
* value was only knowable *at* submit, so no validation, no draft, no preview.
|
|
144
|
+
* Here the value is always readable, and the hidden-input trick disappears.
|
|
145
|
+
*
|
|
146
|
+
* **Nothing in here fetches.** Catalogs come in as data, the extension search is
|
|
147
|
+
* a callback, the GitHub combobox is a slot, and `onSubmit` hands the value back
|
|
148
|
+
* for the app to send. Which sections are shown is a prop, so creation and
|
|
149
|
+
* editing are the same component with a different list — not two screens that
|
|
150
|
+
* drift apart.
|
|
151
|
+
*/
|
|
152
|
+
export function ProjectForm({
|
|
153
|
+
value,
|
|
154
|
+
onChange,
|
|
155
|
+
onSubmit,
|
|
156
|
+
sections = CREATE_SECTIONS,
|
|
157
|
+
advancedSections = DEFAULT_ADVANCED,
|
|
158
|
+
advancedLabel,
|
|
159
|
+
advancedHint,
|
|
160
|
+
defaultAdvancedOpen,
|
|
161
|
+
images = [],
|
|
162
|
+
features = [],
|
|
163
|
+
imagePicker,
|
|
164
|
+
featurePicker,
|
|
165
|
+
githubRepos,
|
|
166
|
+
github,
|
|
167
|
+
connectUrl,
|
|
168
|
+
renderRepoField,
|
|
169
|
+
githubIcon,
|
|
170
|
+
onSearchExtensions,
|
|
171
|
+
savedSecrets,
|
|
172
|
+
extras,
|
|
173
|
+
manifest = true,
|
|
174
|
+
submitLabel = "Save",
|
|
175
|
+
submittingLabel = "Saving…",
|
|
176
|
+
submitting = false,
|
|
177
|
+
error,
|
|
178
|
+
manifestCaption,
|
|
179
|
+
manifestFooter,
|
|
180
|
+
manifestTitle,
|
|
181
|
+
className,
|
|
182
|
+
}: ProjectFormProps) {
|
|
183
|
+
const patch = (next: Partial<ProjectFormValue>) => onChange({ ...value, ...next })
|
|
184
|
+
|
|
185
|
+
const rows = manifestRows(value, sections)
|
|
186
|
+
|
|
187
|
+
const advanced = sections.filter((id) => advancedSections.includes(id))
|
|
188
|
+
const primary = sections.filter((id) => !advancedSections.includes(id))
|
|
189
|
+
const summary = advancedSummary(value, advanced)
|
|
190
|
+
// Start open when there's already something in there — the initial value is
|
|
191
|
+
// read once, on mount, so a later edit can't yank the fold open under the
|
|
192
|
+
// user's cursor.
|
|
193
|
+
const [advancedOpen, setAdvancedOpen] = useState(() => defaultAdvancedOpen ?? summary.length > 0)
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
// The container is this wrapper, not the form: a container query styles a
|
|
197
|
+
// container's DESCENDANTS, never the element that declares it — put both on
|
|
198
|
+
// one node and the query silently never matches.
|
|
199
|
+
//
|
|
200
|
+
// And it's a container query rather than `lg:` because the form is a full
|
|
201
|
+
// page in the dashboard and a ~700 px panel in the showcase. Keyed on the
|
|
202
|
+
// viewport, it split into two columns in both, squeezing the catalog cards
|
|
203
|
+
// to 130 px in the narrow one. Now it reads its own width.
|
|
204
|
+
<div className={cn("@container/form", className)}>
|
|
205
|
+
<form
|
|
206
|
+
data-slot="project-form"
|
|
207
|
+
onSubmit={(e) => {
|
|
208
|
+
e.preventDefault()
|
|
209
|
+
onSubmit?.(value)
|
|
210
|
+
}}
|
|
211
|
+
className={cn(
|
|
212
|
+
manifest
|
|
213
|
+
? "grid gap-6 @min-[60rem]/form:grid-cols-[1fr_minmax(320px,360px)] @min-[60rem]/form:items-start"
|
|
214
|
+
: "block"
|
|
215
|
+
)}
|
|
216
|
+
>
|
|
217
|
+
<div className="space-y-4">
|
|
218
|
+
{primary.map((id, index) => section(id, index))}
|
|
219
|
+
|
|
220
|
+
{advanced.length > 0 && (
|
|
221
|
+
<AdvancedDisclosure
|
|
222
|
+
open={advancedOpen}
|
|
223
|
+
onOpenChange={setAdvancedOpen}
|
|
224
|
+
label={advancedLabel}
|
|
225
|
+
hint={advancedHint}
|
|
226
|
+
summary={summary}
|
|
227
|
+
>
|
|
228
|
+
{advanced.map((id, index) => section(id, primary.length + index))}
|
|
229
|
+
</AdvancedDisclosure>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
{manifest && (
|
|
234
|
+
<div className="@min-[60rem]/form:sticky @min-[60rem]/form:top-20">
|
|
235
|
+
<BuildManifest
|
|
236
|
+
rows={rows}
|
|
237
|
+
submitLabel={submitLabel}
|
|
238
|
+
submittingLabel={submittingLabel}
|
|
239
|
+
submitting={submitting}
|
|
240
|
+
disabled={!value.name.trim()}
|
|
241
|
+
error={error}
|
|
242
|
+
caption={manifestCaption}
|
|
243
|
+
footer={manifestFooter}
|
|
244
|
+
title={manifestTitle}
|
|
245
|
+
/>
|
|
246
|
+
</div>
|
|
247
|
+
)}
|
|
248
|
+
</form>
|
|
249
|
+
</div>
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
/** One section, numbered by its position in the whole form (folded or not). */
|
|
253
|
+
function section(id: ProjectFormSectionId, index: number) {
|
|
254
|
+
const meta = SECTION_META[id]
|
|
255
|
+
return (
|
|
256
|
+
<FormSection
|
|
257
|
+
key={id}
|
|
258
|
+
n={index + 1}
|
|
259
|
+
icon={meta.icon}
|
|
260
|
+
accent={meta.accent}
|
|
261
|
+
title={meta.title}
|
|
262
|
+
hint={meta.hint}
|
|
263
|
+
// The stagger is an inline delay rather than ten Tailwind variants;
|
|
264
|
+
// capped, or a long form's last section would arrive noticeably after
|
|
265
|
+
// you've already scrolled to it.
|
|
266
|
+
style={{ animationDelay: `${Math.min(index, 8) * 40}ms` }}
|
|
267
|
+
>
|
|
268
|
+
{renderSection(id)}
|
|
269
|
+
{extras?.[id]}
|
|
270
|
+
</FormSection>
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function renderSection(id: ProjectFormSectionId): ReactNode {
|
|
275
|
+
switch (id) {
|
|
276
|
+
case "identity":
|
|
277
|
+
return (
|
|
278
|
+
<div className="space-y-4">
|
|
279
|
+
<Field label="Name" htmlFor="project-name">
|
|
280
|
+
<Input
|
|
281
|
+
id="project-name"
|
|
282
|
+
name="name"
|
|
283
|
+
placeholder="my-project"
|
|
284
|
+
required
|
|
285
|
+
value={value.name}
|
|
286
|
+
onChange={(e) => patch({ name: e.target.value })}
|
|
287
|
+
/>
|
|
288
|
+
</Field>
|
|
289
|
+
<Field label="Description" htmlFor="project-description">
|
|
290
|
+
<Textarea
|
|
291
|
+
id="project-description"
|
|
292
|
+
name="description"
|
|
293
|
+
rows={2}
|
|
294
|
+
placeholder="What is this project about?"
|
|
295
|
+
value={value.description}
|
|
296
|
+
onChange={(e) => patch({ description: e.target.value })}
|
|
297
|
+
/>
|
|
298
|
+
</Field>
|
|
299
|
+
</div>
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
case "image":
|
|
303
|
+
return (
|
|
304
|
+
<ImagePicker
|
|
305
|
+
images={images}
|
|
306
|
+
value={value.image}
|
|
307
|
+
onChange={(image) => patch({ image })}
|
|
308
|
+
layout={imagePicker?.layout}
|
|
309
|
+
detail={imagePicker?.detail}
|
|
310
|
+
/>
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
case "repositories":
|
|
314
|
+
return (
|
|
315
|
+
<RepoList
|
|
316
|
+
repos={value.repositories}
|
|
317
|
+
onChange={(repositories) => patch({ repositories })}
|
|
318
|
+
githubRepos={githubRepos}
|
|
319
|
+
github={github}
|
|
320
|
+
connectUrl={connectUrl}
|
|
321
|
+
renderRepoField={renderRepoField}
|
|
322
|
+
githubIcon={githubIcon}
|
|
323
|
+
/>
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
case "features":
|
|
327
|
+
return (
|
|
328
|
+
<FeaturePicker
|
|
329
|
+
features={features}
|
|
330
|
+
value={value.features}
|
|
331
|
+
onChange={(next) => patch({ features: next })}
|
|
332
|
+
layout={featurePicker?.layout}
|
|
333
|
+
detail={featurePicker?.detail}
|
|
334
|
+
/>
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
case "extensions":
|
|
338
|
+
return (
|
|
339
|
+
<ExtensionPicker
|
|
340
|
+
value={value.vscodeExtensions}
|
|
341
|
+
onChange={(vscodeExtensions) => patch({ vscodeExtensions })}
|
|
342
|
+
onSearch={onSearchExtensions}
|
|
343
|
+
/>
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
case "lifecycle":
|
|
347
|
+
return (
|
|
348
|
+
<div className="space-y-4">
|
|
349
|
+
<Field label="Post-create command" htmlFor="post-create" hint="Runs once after container creation">
|
|
350
|
+
<Input
|
|
351
|
+
id="post-create"
|
|
352
|
+
name="postCreateCommand"
|
|
353
|
+
placeholder="npm install"
|
|
354
|
+
className="font-mono text-xs"
|
|
355
|
+
value={value.postCreateCommand}
|
|
356
|
+
onChange={(e) => patch({ postCreateCommand: e.target.value })}
|
|
357
|
+
/>
|
|
358
|
+
</Field>
|
|
359
|
+
<Field label="Post-start command" htmlFor="post-start" hint="Runs on every container start">
|
|
360
|
+
<Input
|
|
361
|
+
id="post-start"
|
|
362
|
+
name="postStartCommand"
|
|
363
|
+
placeholder="npm run dev"
|
|
364
|
+
className="font-mono text-xs"
|
|
365
|
+
value={value.postStartCommand}
|
|
366
|
+
onChange={(e) => patch({ postStartCommand: e.target.value })}
|
|
367
|
+
/>
|
|
368
|
+
</Field>
|
|
369
|
+
</div>
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
case "ports":
|
|
373
|
+
return (
|
|
374
|
+
<Field hint="Comma-separated port numbers">
|
|
375
|
+
<Input
|
|
376
|
+
name="forwardPorts"
|
|
377
|
+
placeholder="3000, 8080"
|
|
378
|
+
// Kept as text while typing: parsing on every keystroke would
|
|
379
|
+
// delete the comma the moment you type it.
|
|
380
|
+
value={value.forwardPorts.join(", ")}
|
|
381
|
+
onChange={(e) => patch({ forwardPorts: parsePorts(e.target.value) })}
|
|
382
|
+
/>
|
|
383
|
+
</Field>
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
case "prewarm":
|
|
387
|
+
return (
|
|
388
|
+
<Field
|
|
389
|
+
hint={
|
|
390
|
+
<>
|
|
391
|
+
One image per line. Baked into the prebuilt image and <code>docker load</code>-ed at first
|
|
392
|
+
start — turns an internal <code>docker compose up</code> from minutes into seconds.
|
|
393
|
+
</>
|
|
394
|
+
}
|
|
395
|
+
>
|
|
396
|
+
<Textarea
|
|
397
|
+
name="prewarmImages"
|
|
398
|
+
rows={3}
|
|
399
|
+
className="font-mono text-xs"
|
|
400
|
+
placeholder={"traefik:v3\nnode:24\nghcr.io/org/image:tag"}
|
|
401
|
+
value={value.prewarmImages.join("\n")}
|
|
402
|
+
onChange={(e) => patch({ prewarmImages: e.target.value.split("\n").map((l) => l.trim()).filter(Boolean) })}
|
|
403
|
+
/>
|
|
404
|
+
</Field>
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
case "docker":
|
|
408
|
+
return (
|
|
409
|
+
<label className="flex cursor-pointer items-start justify-between gap-4 rounded-md border bg-muted/30 px-3 py-2.5">
|
|
410
|
+
<span className="space-y-0.5">
|
|
411
|
+
<span className="block text-sm leading-none font-medium">Enable Docker-in-Docker</span>
|
|
412
|
+
<span className="block text-xs text-muted-foreground">
|
|
413
|
+
Runs each worker privileged with <code>dockerd</code> and dedicated{" "}
|
|
414
|
+
<code>/var/lib/docker</code> volumes. Use with a base image that bakes Docker — no{" "}
|
|
415
|
+
<code>docker-in-docker</code> feature needed.
|
|
416
|
+
</span>
|
|
417
|
+
</span>
|
|
418
|
+
<Switch
|
|
419
|
+
checked={value.dockerInDocker}
|
|
420
|
+
onCheckedChange={(checked) => patch({ dockerInDocker: checked })}
|
|
421
|
+
/>
|
|
422
|
+
</label>
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
case "secrets":
|
|
426
|
+
return (
|
|
427
|
+
<SecretList
|
|
428
|
+
drafts={value.secrets}
|
|
429
|
+
onDraftsChange={(secrets) => patch({ secrets })}
|
|
430
|
+
saved={savedSecrets}
|
|
431
|
+
removedIds={value.removedSecretIds}
|
|
432
|
+
onRemovedIdsChange={(removedSecretIds) => patch({ removedSecretIds })}
|
|
433
|
+
hint="Shared across every member working on this project. Override at org or personal level anytime."
|
|
434
|
+
/>
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** `"3000, 8080, "` → `[3000, 8080]`. Anything that isn't a number is dropped. */
|
|
441
|
+
function parsePorts(raw: string): number[] {
|
|
442
|
+
return raw
|
|
443
|
+
.split(",")
|
|
444
|
+
.map((p) => Number.parseInt(p.trim(), 10))
|
|
445
|
+
.filter((p) => Number.isFinite(p) && p > 0)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The manifest lines, derived from the value — never stored. Only the sections
|
|
450
|
+
* actually shown get a line: a creation form that hides ports shouldn't recap
|
|
451
|
+
* an empty "ports" row.
|
|
452
|
+
*/
|
|
453
|
+
export function manifestRows(value: ProjectFormValue, sections: ProjectFormSectionId[]): ManifestRow[] {
|
|
454
|
+
const shown = new Set(sections)
|
|
455
|
+
const rows: ManifestRow[] = []
|
|
456
|
+
|
|
457
|
+
if (shown.has("identity")) rows.push({ label: "name", value: value.name || "untitled", done: !!value.name.trim() })
|
|
458
|
+
if (shown.has("image"))
|
|
459
|
+
rows.push({ label: "image", value: value.image ? (value.image.split("/").pop() ?? value.image) : "—", done: !!value.image })
|
|
460
|
+
if (shown.has("repositories")) {
|
|
461
|
+
const n = value.repositories.length
|
|
462
|
+
rows.push({ label: "sources", value: `${n} repo${n === 1 ? "" : "s"}`, done: n > 0 })
|
|
463
|
+
}
|
|
464
|
+
if (shown.has("features") || shown.has("extensions"))
|
|
465
|
+
rows.push({
|
|
466
|
+
label: "features",
|
|
467
|
+
value: `${value.features.length} · ${value.vscodeExtensions.length} ext`,
|
|
468
|
+
done: value.features.length > 0 || value.vscodeExtensions.length > 0,
|
|
469
|
+
})
|
|
470
|
+
if (shown.has("lifecycle"))
|
|
471
|
+
rows.push({
|
|
472
|
+
label: "lifecycle",
|
|
473
|
+
value:
|
|
474
|
+
[value.postCreateCommand && "create", value.postStartCommand && "start"].filter(Boolean).join(" + ") ||
|
|
475
|
+
"default",
|
|
476
|
+
done: !!(value.postCreateCommand || value.postStartCommand),
|
|
477
|
+
})
|
|
478
|
+
if (shown.has("ports"))
|
|
479
|
+
rows.push({ label: "ports", value: value.forwardPorts.join(", ") || "—", done: value.forwardPorts.length > 0 })
|
|
480
|
+
if (shown.has("prewarm"))
|
|
481
|
+
rows.push({ label: "prewarm", value: `${value.prewarmImages.length}`, done: value.prewarmImages.length > 0 })
|
|
482
|
+
if (shown.has("docker"))
|
|
483
|
+
rows.push({ label: "docker", value: value.dockerInDocker ? "in-docker" : "off", done: value.dockerInDocker })
|
|
484
|
+
if (shown.has("secrets"))
|
|
485
|
+
rows.push({ label: "secrets", value: `${value.secrets.length}`, done: value.secrets.length > 0 })
|
|
486
|
+
|
|
487
|
+
return rows
|
|
488
|
+
}
|