@spunto/design-system 0.9.3 → 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 +102 -4
- package/package.json +9 -1
- package/src/components/command-palette.tsx +702 -0
- 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/kbd.tsx +117 -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/src/index.ts +35 -0
- package/styles.css +13 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
import { GitBranchIcon, Trash2Icon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils"
|
|
7
|
+
import { Button } from "../button"
|
|
8
|
+
import { Input } from "../input"
|
|
9
|
+
import type { ProjectRepo } from "./types"
|
|
10
|
+
|
|
11
|
+
/** A repository the user can pick from a connected GitHub account. */
|
|
12
|
+
export interface GithubRepoOption {
|
|
13
|
+
fullName: string
|
|
14
|
+
private?: boolean
|
|
15
|
+
installationId?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** State of the org's GitHub App connection, as far as this list needs it. */
|
|
19
|
+
export interface GithubConnection {
|
|
20
|
+
connected?: boolean
|
|
21
|
+
appSlug?: string
|
|
22
|
+
installedAccounts?: { login: string; avatarUrl?: string | null }[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Renders the GitHub repository field. A slot, because a real combobox is an
|
|
27
|
+
* app-level dependency (the dashboard has a Base UI one): without it the list
|
|
28
|
+
* falls back to an `<input list>` + `<datalist>`, which needs nothing, works
|
|
29
|
+
* with a keyboard, and is honest about being a plain text field.
|
|
30
|
+
*/
|
|
31
|
+
export type RepoFieldRender = (props: {
|
|
32
|
+
value: string
|
|
33
|
+
onChange: (value: string) => void
|
|
34
|
+
options: GithubRepoOption[]
|
|
35
|
+
placeholder: string
|
|
36
|
+
id: string
|
|
37
|
+
}) => ReactNode
|
|
38
|
+
|
|
39
|
+
export interface RepoListProps {
|
|
40
|
+
repos: ProjectRepo[]
|
|
41
|
+
onChange: (repos: ProjectRepo[]) => void
|
|
42
|
+
/** Repositories reachable through the connected GitHub App. */
|
|
43
|
+
githubRepos?: GithubRepoOption[]
|
|
44
|
+
github?: GithubConnection
|
|
45
|
+
/** Where "Connect GitHub" points. The package knows no route of its own. */
|
|
46
|
+
connectUrl?: string
|
|
47
|
+
renderRepoField?: RepoFieldRender
|
|
48
|
+
/** Icon for GitHub rows — the app passes its brand glyph; defaults to a git branch. */
|
|
49
|
+
githubIcon?: ReactNode
|
|
50
|
+
className?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Human label + default workspace dir out of a raw clone URL. */
|
|
54
|
+
export function deriveFromGitUrl(url: string): { label: string; repoName: string } {
|
|
55
|
+
const path = url
|
|
56
|
+
.trim()
|
|
57
|
+
.replace(/^git@[^:]+:/, "")
|
|
58
|
+
.replace(/^ssh:\/\/[^/]+\//, "")
|
|
59
|
+
.replace(/^https?:\/\/[^/]+\//, "")
|
|
60
|
+
.replace(/\.git$/, "")
|
|
61
|
+
const segments = path.split("/").filter(Boolean)
|
|
62
|
+
const repoName = segments[segments.length - 1] ?? ""
|
|
63
|
+
const label = segments.slice(-2).join("/") || path
|
|
64
|
+
return { label, repoName }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The repository field's slot in a row: its own full-width line on a phone,
|
|
69
|
+
* a shared column as soon as there's room for one.
|
|
70
|
+
*/
|
|
71
|
+
const ROW_LEAD = "relative min-w-0 basis-full @min-[30rem]/repos:basis-0 @min-[30rem]/repos:flex-1"
|
|
72
|
+
|
|
73
|
+
let seq = 0
|
|
74
|
+
/**
|
|
75
|
+
* Identity for a row that doesn't exist server-side yet. A counter rather than
|
|
76
|
+
* `crypto.randomUUID()`: it only has to be unique among the rows of one form,
|
|
77
|
+
* and a counter is stable under SSR, where `randomUUID` would hand the server
|
|
78
|
+
* and the client two different keys.
|
|
79
|
+
*/
|
|
80
|
+
function localRepoId(): string {
|
|
81
|
+
seq += 1
|
|
82
|
+
return `repo-${seq}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The repositories a project clones into `/workspace`, one row each: where the
|
|
87
|
+
* code comes from, and the directory it lands in.
|
|
88
|
+
*
|
|
89
|
+
* **Fully controlled** — it never keeps a repo in local state. The one piece of
|
|
90
|
+
* cleverness it does keep is `workspacePathTouched`: the path follows the repo
|
|
91
|
+
* name while you're picking a repo, and stops the moment you type your own.
|
|
92
|
+
* Without that flag, choosing a repo silently overwrites a path you had set.
|
|
93
|
+
*/
|
|
94
|
+
export function RepoList({
|
|
95
|
+
repos,
|
|
96
|
+
onChange,
|
|
97
|
+
githubRepos = [],
|
|
98
|
+
github,
|
|
99
|
+
connectUrl,
|
|
100
|
+
renderRepoField,
|
|
101
|
+
githubIcon,
|
|
102
|
+
className,
|
|
103
|
+
}: RepoListProps) {
|
|
104
|
+
const hasGithubRepos = githubRepos.length > 0
|
|
105
|
+
const hasGitRepos = repos.some((r) => r.provider === "git")
|
|
106
|
+
|
|
107
|
+
const patch = (id: string, next: Partial<ProjectRepo>) =>
|
|
108
|
+
onChange(repos.map((r) => (r.id === id ? { ...r, ...next } : r)))
|
|
109
|
+
|
|
110
|
+
function setProject(repo: ProjectRepo, value: string) {
|
|
111
|
+
const repoName = value.split("/")[1] ?? value
|
|
112
|
+
patch(repo.id, {
|
|
113
|
+
project: value,
|
|
114
|
+
// Tag the row with the installation that grants access, so a worker clones
|
|
115
|
+
// it with the right token when the org connected several GitHub accounts.
|
|
116
|
+
installationId: githubRepos.find((r) => r.fullName === value)?.installationId,
|
|
117
|
+
...(repo.workspacePathTouched ? {} : { workspacePath: repoName }),
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function setCloneUrl(repo: ProjectRepo, value: string) {
|
|
122
|
+
const { label, repoName } = deriveFromGitUrl(value)
|
|
123
|
+
patch(repo.id, {
|
|
124
|
+
cloneUrl: value,
|
|
125
|
+
project: label,
|
|
126
|
+
...(repo.workspacePathTouched ? {} : { workspacePath: repoName }),
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div className={cn("@container/repos space-y-2", className)}>
|
|
132
|
+
{repos.length > 0 && (
|
|
133
|
+
<div className="mb-1 hidden grid-cols-[1fr_1fr_auto] gap-2 px-1 text-xs text-muted-foreground @min-[30rem]/repos:grid">
|
|
134
|
+
<span>Repository</span>
|
|
135
|
+
<span>Workspace path</span>
|
|
136
|
+
<span />
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
{repos.map((repo) => (
|
|
141
|
+
// `flex-wrap` + `basis-full`, not a grid: at phone width the two-column
|
|
142
|
+
// grid gave the `auto` track to the workspace-path input, which sized to
|
|
143
|
+
// its content and squeezed the repository field down to its icon. Here
|
|
144
|
+
// the repository takes a full row of its own until there's room, and the
|
|
145
|
+
// path + bin share the next one.
|
|
146
|
+
<div key={repo.id} className="flex flex-wrap items-center gap-2">
|
|
147
|
+
{repo.provider === "git" ? (
|
|
148
|
+
<div className={ROW_LEAD}>
|
|
149
|
+
<GitBranchIcon className="absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
150
|
+
<Input
|
|
151
|
+
className="pl-8 font-mono text-xs"
|
|
152
|
+
placeholder="git@gitlab.com:group/repo.git"
|
|
153
|
+
value={repo.cloneUrl ?? ""}
|
|
154
|
+
onChange={(e) => setCloneUrl(repo, e.target.value)}
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
) : renderRepoField && hasGithubRepos ? (
|
|
158
|
+
<div className={ROW_LEAD}>
|
|
159
|
+
{renderRepoField({
|
|
160
|
+
value: repo.project,
|
|
161
|
+
onChange: (value) => setProject(repo, value),
|
|
162
|
+
options: githubRepos,
|
|
163
|
+
placeholder: "Search or type owner/repo…",
|
|
164
|
+
id: repo.id,
|
|
165
|
+
})}
|
|
166
|
+
</div>
|
|
167
|
+
) : (
|
|
168
|
+
<div className={cn(ROW_LEAD, "relative")}>
|
|
169
|
+
<span className="absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground">
|
|
170
|
+
{githubIcon ?? <GitBranchIcon className="h-3.5 w-3.5" />}
|
|
171
|
+
</span>
|
|
172
|
+
<Input
|
|
173
|
+
className="pl-8"
|
|
174
|
+
list={hasGithubRepos ? `repos-${repo.id}` : undefined}
|
|
175
|
+
placeholder="owner/repo"
|
|
176
|
+
value={repo.project}
|
|
177
|
+
onChange={(e) => setProject(repo, e.target.value)}
|
|
178
|
+
/>
|
|
179
|
+
{hasGithubRepos && (
|
|
180
|
+
<datalist id={`repos-${repo.id}`}>
|
|
181
|
+
{githubRepos.map((r) => (
|
|
182
|
+
<option key={r.fullName} value={r.fullName} />
|
|
183
|
+
))}
|
|
184
|
+
</datalist>
|
|
185
|
+
)}
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
|
|
189
|
+
<Input
|
|
190
|
+
placeholder="e.g. app"
|
|
191
|
+
aria-label="Workspace path"
|
|
192
|
+
className="min-w-0 flex-1"
|
|
193
|
+
value={repo.workspacePath}
|
|
194
|
+
onChange={(e) => patch(repo.id, { workspacePath: e.target.value, workspacePathTouched: true })}
|
|
195
|
+
/>
|
|
196
|
+
|
|
197
|
+
<Button
|
|
198
|
+
type="button"
|
|
199
|
+
variant="ghost"
|
|
200
|
+
size="icon"
|
|
201
|
+
className="shrink-0"
|
|
202
|
+
aria-label={`Remove ${repo.project || "repository"}`}
|
|
203
|
+
onClick={() => onChange(repos.filter((r) => r.id !== repo.id))}
|
|
204
|
+
>
|
|
205
|
+
<Trash2Icon className="h-4 w-4" />
|
|
206
|
+
</Button>
|
|
207
|
+
</div>
|
|
208
|
+
))}
|
|
209
|
+
|
|
210
|
+
<div className="flex flex-wrap gap-2">
|
|
211
|
+
<Button
|
|
212
|
+
type="button"
|
|
213
|
+
variant="outline"
|
|
214
|
+
size="sm"
|
|
215
|
+
onClick={() =>
|
|
216
|
+
onChange([...repos, { id: localRepoId(), provider: "github", project: "", workspacePath: "" }])
|
|
217
|
+
}
|
|
218
|
+
>
|
|
219
|
+
<span className="mr-1 flex h-4 w-4 items-center justify-center">
|
|
220
|
+
{githubIcon ?? <GitBranchIcon className="h-4 w-4" />}
|
|
221
|
+
</span>
|
|
222
|
+
Add GitHub repo
|
|
223
|
+
</Button>
|
|
224
|
+
<Button
|
|
225
|
+
type="button"
|
|
226
|
+
variant="outline"
|
|
227
|
+
size="sm"
|
|
228
|
+
onClick={() =>
|
|
229
|
+
onChange([...repos, { id: localRepoId(), provider: "git", project: "", workspacePath: "", cloneUrl: "" }])
|
|
230
|
+
}
|
|
231
|
+
>
|
|
232
|
+
<GitBranchIcon className="mr-1 h-4 w-4" />
|
|
233
|
+
Add Git URL
|
|
234
|
+
</Button>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<p className="text-xs text-muted-foreground">
|
|
238
|
+
Cloned into <code>/workspace/<path></code> at worker startup.{" "}
|
|
239
|
+
{connectUrl &&
|
|
240
|
+
(github?.connected ? (
|
|
241
|
+
<a href={connectUrl} className="underline underline-offset-2">
|
|
242
|
+
Reconnect GitHub
|
|
243
|
+
</a>
|
|
244
|
+
) : (
|
|
245
|
+
<>
|
|
246
|
+
<a href={connectUrl} className="underline underline-offset-2">
|
|
247
|
+
Connect GitHub
|
|
248
|
+
</a>{" "}
|
|
249
|
+
to enable private repos for this organization.
|
|
250
|
+
</>
|
|
251
|
+
))}
|
|
252
|
+
</p>
|
|
253
|
+
|
|
254
|
+
{hasGitRepos && (
|
|
255
|
+
<p className="rounded-md border border-dashed p-3 text-xs text-muted-foreground">
|
|
256
|
+
Git URL repos (GitLab, Gitea, Bitbucket, self-hosted…) are cloned with this project's SSH{" "}
|
|
257
|
+
<strong>deploy key</strong>. After saving, register the public key shown on the project page as a{" "}
|
|
258
|
+
<strong>read-only</strong> deploy key on each repo. <code>https://</code> URLs to public repos work
|
|
259
|
+
without a key.
|
|
260
|
+
</p>
|
|
261
|
+
)}
|
|
262
|
+
|
|
263
|
+
{github?.connected && github.installedAccounts && github.installedAccounts.length > 0 && (
|
|
264
|
+
<div className="space-y-2 rounded-md border border-dashed p-3">
|
|
265
|
+
<p className="text-xs font-medium">GitHub App installed on:</p>
|
|
266
|
+
<div className="flex flex-wrap gap-2">
|
|
267
|
+
{github.installedAccounts.map((account) => (
|
|
268
|
+
<span key={account.login} className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs">
|
|
269
|
+
{account.avatarUrl && (
|
|
270
|
+
<img src={account.avatarUrl} alt="" className="h-4 w-4 rounded-sm" />
|
|
271
|
+
)}
|
|
272
|
+
{account.login}
|
|
273
|
+
</span>
|
|
274
|
+
))}
|
|
275
|
+
</div>
|
|
276
|
+
{github.appSlug && (
|
|
277
|
+
<p className="text-xs text-muted-foreground">
|
|
278
|
+
<a
|
|
279
|
+
href={`https://github.com/apps/${github.appSlug}/installations/new`}
|
|
280
|
+
target="_blank"
|
|
281
|
+
rel="noopener noreferrer"
|
|
282
|
+
className="underline underline-offset-2"
|
|
283
|
+
>
|
|
284
|
+
Install on another account
|
|
285
|
+
</a>{" "}
|
|
286
|
+
to access more repos
|
|
287
|
+
{connectUrl && (
|
|
288
|
+
<>
|
|
289
|
+
, then{" "}
|
|
290
|
+
<a href={connectUrl} className="underline underline-offset-2">
|
|
291
|
+
reconnect
|
|
292
|
+
</a>
|
|
293
|
+
</>
|
|
294
|
+
)}
|
|
295
|
+
.
|
|
296
|
+
</p>
|
|
297
|
+
)}
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
300
|
+
</div>
|
|
301
|
+
)
|
|
302
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import { PlusIcon, Trash2Icon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils"
|
|
7
|
+
import { Input } from "../input"
|
|
8
|
+
import type { ProjectSecretDraft, ProjectSecretRef } from "./types"
|
|
9
|
+
|
|
10
|
+
/** Env var names, the shell convention: `MY_SECRET`, never `my-secret`. */
|
|
11
|
+
const NAME_PATTERN = /^[A-Z][A-Z0-9_]*$/
|
|
12
|
+
|
|
13
|
+
export interface SecretListProps {
|
|
14
|
+
/** Secrets typed in this session, values still in memory. */
|
|
15
|
+
drafts: ProjectSecretDraft[]
|
|
16
|
+
onDraftsChange: (drafts: ProjectSecretDraft[]) => void
|
|
17
|
+
/** Already-saved secrets. Values are never returned by an API — only names show. */
|
|
18
|
+
saved?: ProjectSecretRef[]
|
|
19
|
+
/** Ids of saved secrets staged for removal; they disappear from the list. */
|
|
20
|
+
removedIds?: string[]
|
|
21
|
+
onRemovedIdsChange?: (ids: string[]) => void
|
|
22
|
+
hint?: string
|
|
23
|
+
className?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let seq = 0
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The env vars injected into every workspace: a list of names, and a row to add
|
|
30
|
+
* one.
|
|
31
|
+
*
|
|
32
|
+
* A secret is write-only by nature — the API never returns a value — so the list
|
|
33
|
+
* shows names alone and the two states are told apart by colour: saved ones are
|
|
34
|
+
* neutral, the ones you just typed are flame, because they only exist in this
|
|
35
|
+
* page until you submit.
|
|
36
|
+
*
|
|
37
|
+
* Validation lives here rather than in the parent form: the name rule is a
|
|
38
|
+
* property of a secret, not of the screen it's typed on.
|
|
39
|
+
*/
|
|
40
|
+
export function SecretList({
|
|
41
|
+
drafts,
|
|
42
|
+
onDraftsChange,
|
|
43
|
+
saved = [],
|
|
44
|
+
removedIds = [],
|
|
45
|
+
onRemovedIdsChange,
|
|
46
|
+
hint,
|
|
47
|
+
className,
|
|
48
|
+
}: SecretListProps) {
|
|
49
|
+
const [name, setName] = useState("")
|
|
50
|
+
const [value, setValue] = useState("")
|
|
51
|
+
const [error, setError] = useState<string | null>(null)
|
|
52
|
+
|
|
53
|
+
const visible = saved.filter((s) => !removedIds.includes(s.id))
|
|
54
|
+
|
|
55
|
+
function add() {
|
|
56
|
+
setError(null)
|
|
57
|
+
const key = name.trim().toUpperCase()
|
|
58
|
+
if (!key || !value.trim()) return
|
|
59
|
+
if (!NAME_PATTERN.test(key)) {
|
|
60
|
+
setError("UPPER_SNAKE_CASE required (e.g. MY_SECRET)")
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
if (visible.some((s) => s.name === key) || drafts.some((s) => s.name === key)) {
|
|
64
|
+
setError(`${key} is already set`)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
seq += 1
|
|
68
|
+
onDraftsChange([...drafts, { localId: `secret-${seq}`, name: key, value: value.trim() }])
|
|
69
|
+
setName("")
|
|
70
|
+
setValue("")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className={cn("@container/secrets space-y-3", className)}>
|
|
75
|
+
{(visible.length > 0 || drafts.length > 0) && (
|
|
76
|
+
<div className="space-y-1.5">
|
|
77
|
+
{visible.map((secret) => (
|
|
78
|
+
<Row
|
|
79
|
+
key={secret.id}
|
|
80
|
+
name={secret.name}
|
|
81
|
+
onRemove={onRemovedIdsChange ? () => onRemovedIdsChange([...removedIds, secret.id]) : undefined}
|
|
82
|
+
/>
|
|
83
|
+
))}
|
|
84
|
+
{drafts.map((secret) => (
|
|
85
|
+
<Row
|
|
86
|
+
key={secret.localId}
|
|
87
|
+
name={secret.name}
|
|
88
|
+
pending
|
|
89
|
+
onRemove={() => onDraftsChange(drafts.filter((s) => s.localId !== secret.localId))}
|
|
90
|
+
/>
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
<div className="space-y-1.5">
|
|
96
|
+
{/* Same reason as RepoList: a two-column grid handed the `auto` track to
|
|
97
|
+
the value field, which squeezed NAME down to two characters on a
|
|
98
|
+
phone. Wrapping gives NAME its own line until both fit. */}
|
|
99
|
+
<div className="flex flex-wrap items-end gap-2">
|
|
100
|
+
<Input
|
|
101
|
+
placeholder="NAME"
|
|
102
|
+
aria-label="Secret name"
|
|
103
|
+
value={name}
|
|
104
|
+
spellCheck={false}
|
|
105
|
+
className="h-8 min-w-0 basis-full font-mono text-sm @min-[26rem]/secrets:basis-0 @min-[26rem]/secrets:flex-1"
|
|
106
|
+
onChange={(e) => setName(e.target.value.toUpperCase())}
|
|
107
|
+
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), add())}
|
|
108
|
+
/>
|
|
109
|
+
<Input
|
|
110
|
+
type="password"
|
|
111
|
+
placeholder="value"
|
|
112
|
+
aria-label="Secret value"
|
|
113
|
+
value={value}
|
|
114
|
+
autoComplete="off"
|
|
115
|
+
className="h-8 min-w-0 flex-1 font-mono text-sm"
|
|
116
|
+
onChange={(e) => setValue(e.target.value)}
|
|
117
|
+
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), add())}
|
|
118
|
+
/>
|
|
119
|
+
{/* `type="button"`: this sits inside the project form, and a bare
|
|
120
|
+
button would submit the whole thing on Enter. */}
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
onClick={add}
|
|
124
|
+
aria-label="Add secret"
|
|
125
|
+
className="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border border-input bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
126
|
+
>
|
|
127
|
+
<PlusIcon className="h-3.5 w-3.5" />
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
|
134
|
+
</div>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function Row({ name, pending = false, onRemove }: { name: string; pending?: boolean; onRemove?: () => void }) {
|
|
139
|
+
return (
|
|
140
|
+
<div
|
|
141
|
+
className={cn(
|
|
142
|
+
"flex items-center justify-between gap-3 rounded-md border px-3 py-2",
|
|
143
|
+
pending ? "border-primary/30 bg-primary/5" : "bg-muted/30"
|
|
144
|
+
)}
|
|
145
|
+
>
|
|
146
|
+
<code className={cn("truncate font-mono text-sm", pending && "text-primary")}>{name}</code>
|
|
147
|
+
{onRemove && (
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
onClick={onRemove}
|
|
151
|
+
aria-label={`Remove ${name}`}
|
|
152
|
+
className="shrink-0 cursor-pointer text-muted-foreground transition-colors hover:text-destructive"
|
|
153
|
+
>
|
|
154
|
+
<Trash2Icon className="h-3.5 w-3.5" />
|
|
155
|
+
</button>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// The shape a project form edits, and nothing more.
|
|
2
|
+
//
|
|
3
|
+
// Same rule as the rest of the package: this is NOT an app's OpenAPI type. The
|
|
4
|
+
// dashboard's create payload and its update payload already differ from each
|
|
5
|
+
// other (creation has no ports/prewarm/dind, update has no inline secrets), and
|
|
6
|
+
// Spunto Lite's project is smaller still. So the form owns one **complete**
|
|
7
|
+
// value — every field present, never undefined — and each app maps it to its own
|
|
8
|
+
// payload on submit. A complete value is what makes the form controllable
|
|
9
|
+
// without a single `?? ""` at every input.
|
|
10
|
+
|
|
11
|
+
/** One repository to clone into the workspace. */
|
|
12
|
+
export interface ProjectRepo {
|
|
13
|
+
/** Client-side identity, stable across re-renders. Not the API's id. */
|
|
14
|
+
id: string
|
|
15
|
+
/** `github` resolves through the connected GitHub App; `git` clones a raw URL with the deploy key. */
|
|
16
|
+
provider: "github" | "git"
|
|
17
|
+
/** `owner/repo` for GitHub, a human label for a raw URL. */
|
|
18
|
+
project: string
|
|
19
|
+
/** Directory under `/workspace`. Auto-derived from the repo name until the user edits it. */
|
|
20
|
+
workspacePath: string
|
|
21
|
+
/**
|
|
22
|
+
* True once the user typed their own path — the form then stops re-deriving it.
|
|
23
|
+
* Without it, picking a repo silently overwrites a path the user had set.
|
|
24
|
+
*/
|
|
25
|
+
workspacePathTouched?: boolean
|
|
26
|
+
/** Which GitHub App installation grants access (an org can connect several). */
|
|
27
|
+
installationId?: number
|
|
28
|
+
/** Raw clone URL, `git` provider only. */
|
|
29
|
+
cloneUrl?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A devcontainer feature, as the form carries it. */
|
|
33
|
+
export interface ProjectFeatureSelection {
|
|
34
|
+
id: string
|
|
35
|
+
/** Set for a hand-typed OCI ref that isn't in the catalog. */
|
|
36
|
+
ociRef?: string
|
|
37
|
+
options?: Record<string, string>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A secret the user typed but that isn't saved yet. Values never come back from an API. */
|
|
41
|
+
export interface ProjectSecretDraft {
|
|
42
|
+
/** Client-side identity — a secret has no id until it's persisted. */
|
|
43
|
+
localId: string
|
|
44
|
+
name: string
|
|
45
|
+
value: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A secret that already exists server-side. The form can only remove it. */
|
|
49
|
+
export interface ProjectSecretRef {
|
|
50
|
+
id: string
|
|
51
|
+
name: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Everything the form edits. Every field is present — see the note at the top. */
|
|
55
|
+
export interface ProjectFormValue {
|
|
56
|
+
name: string
|
|
57
|
+
description: string
|
|
58
|
+
image: string
|
|
59
|
+
repositories: ProjectRepo[]
|
|
60
|
+
features: ProjectFeatureSelection[]
|
|
61
|
+
vscodeExtensions: string[]
|
|
62
|
+
postCreateCommand: string
|
|
63
|
+
postStartCommand: string
|
|
64
|
+
/** Secrets typed in this session. */
|
|
65
|
+
secrets: ProjectSecretDraft[]
|
|
66
|
+
/** Ids of already-saved secrets the user asked to remove. */
|
|
67
|
+
removedSecretIds: string[]
|
|
68
|
+
forwardPorts: number[]
|
|
69
|
+
prewarmImages: string[]
|
|
70
|
+
dockerInDocker: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type ProjectFormSectionId =
|
|
74
|
+
| "identity"
|
|
75
|
+
| "image"
|
|
76
|
+
| "repositories"
|
|
77
|
+
| "features"
|
|
78
|
+
| "extensions"
|
|
79
|
+
| "lifecycle"
|
|
80
|
+
| "ports"
|
|
81
|
+
| "prewarm"
|
|
82
|
+
| "docker"
|
|
83
|
+
| "secrets"
|
|
84
|
+
|
|
85
|
+
/** Creation: the seven sections that matter before a project exists. */
|
|
86
|
+
export const CREATE_SECTIONS: ProjectFormSectionId[] = [
|
|
87
|
+
"identity",
|
|
88
|
+
"image",
|
|
89
|
+
"repositories",
|
|
90
|
+
"features",
|
|
91
|
+
"extensions",
|
|
92
|
+
"lifecycle",
|
|
93
|
+
"secrets",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
/** Editing: everything, including the runtime knobs you only tune once it runs. */
|
|
97
|
+
export const EDIT_SECTIONS: ProjectFormSectionId[] = [
|
|
98
|
+
"identity",
|
|
99
|
+
"image",
|
|
100
|
+
"repositories",
|
|
101
|
+
"features",
|
|
102
|
+
"extensions",
|
|
103
|
+
"lifecycle",
|
|
104
|
+
"ports",
|
|
105
|
+
"prewarm",
|
|
106
|
+
"docker",
|
|
107
|
+
"secrets",
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
/** An empty, complete value — the starting point of a creation form. */
|
|
111
|
+
export function emptyProjectFormValue(): ProjectFormValue {
|
|
112
|
+
return {
|
|
113
|
+
name: "",
|
|
114
|
+
description: "",
|
|
115
|
+
image: "",
|
|
116
|
+
repositories: [],
|
|
117
|
+
features: [],
|
|
118
|
+
vscodeExtensions: [],
|
|
119
|
+
postCreateCommand: "",
|
|
120
|
+
postStartCommand: "",
|
|
121
|
+
secrets: [],
|
|
122
|
+
removedSecretIds: [],
|
|
123
|
+
forwardPorts: [],
|
|
124
|
+
prewarmImages: [],
|
|
125
|
+
dockerInDocker: false,
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Fill the gaps of a partial value — what an app has after mapping an existing
|
|
131
|
+
* project, where half the fields are nullable. Keeps the form's "everything is
|
|
132
|
+
* present" invariant without pushing a dozen `?? ""` onto every caller.
|
|
133
|
+
*/
|
|
134
|
+
export function toProjectFormValue(partial?: Partial<ProjectFormValue> | null): ProjectFormValue {
|
|
135
|
+
return { ...emptyProjectFormValue(), ...(partial ?? {}) }
|
|
136
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -91,6 +91,41 @@ export {
|
|
|
91
91
|
AlertDialogDescription,
|
|
92
92
|
} from "./components/alert-dialog"
|
|
93
93
|
|
|
94
|
+
// CommandPalette — the ⌘K palette, domain-free: an input, groups, items, and a
|
|
95
|
+
// jump. Base UI `Autocomplete` (roles + ↑↓ navigation) rendered inline inside a
|
|
96
|
+
// Base UI `Dialog` (focus trap + Escape), portalled into the provider's overlay
|
|
97
|
+
// container like every other overlay here.
|
|
98
|
+
export {
|
|
99
|
+
CommandPalette,
|
|
100
|
+
CommandPaletteInput,
|
|
101
|
+
CommandPaletteList,
|
|
102
|
+
CommandPaletteGroup,
|
|
103
|
+
CommandPaletteSeparator,
|
|
104
|
+
CommandPaletteItem,
|
|
105
|
+
CommandPaletteEmpty,
|
|
106
|
+
CommandPaletteLoading,
|
|
107
|
+
CommandPaletteFooter,
|
|
108
|
+
CommandPaletteTrigger,
|
|
109
|
+
} from "./components/command-palette"
|
|
110
|
+
export type {
|
|
111
|
+
CommandPaletteProps,
|
|
112
|
+
CommandPaletteInputProps,
|
|
113
|
+
CommandPaletteGroupProps,
|
|
114
|
+
CommandPaletteItemProps,
|
|
115
|
+
CommandPaletteLoadingProps,
|
|
116
|
+
CommandPaletteFooterProps,
|
|
117
|
+
CommandPaletteTriggerProps,
|
|
118
|
+
CommandPaletteFilter,
|
|
119
|
+
CommandPaletteFilterItem,
|
|
120
|
+
CommandPaletteLinkProps,
|
|
121
|
+
CommandPaletteLinkRender,
|
|
122
|
+
} from "./components/command-palette"
|
|
123
|
+
|
|
124
|
+
// Kbd — one keyboard cap, or a whole shortcut (`keys="mod+k"` → ⌘ K). Used by the
|
|
125
|
+
// palette, exported on its own for anywhere else a shortcut needs drawing.
|
|
126
|
+
export { Kbd } from "./components/kbd"
|
|
127
|
+
export type { KbdProps } from "./components/kbd"
|
|
128
|
+
|
|
94
129
|
// Toasts — imperative `toast()` API + the umbrella client provider that renders
|
|
95
130
|
// them. `SpuntoProvider` mounts the toast viewport, the tooltip delay group, and
|
|
96
131
|
// the overlay portal container that dialogs/selects render into.
|
package/styles.css
CHANGED
|
@@ -513,3 +513,16 @@
|
|
|
513
513
|
[data-slot="image-pull-sweep"] { animation: none; opacity: 0.45; }
|
|
514
514
|
[data-slot="image-pull"] * { transition-duration: 0.01ms !important; }
|
|
515
515
|
}
|
|
516
|
+
|
|
517
|
+
/* ---------------------------------------------------------------------------
|
|
518
|
+
FormSection — entrance.
|
|
519
|
+
A long form arrives as a stack of cards; each one lifts in slightly after the
|
|
520
|
+
previous (the delay is an inline style, since it depends on the section's
|
|
521
|
+
index). Deliberately CSS and not a motion library: the package won't take a
|
|
522
|
+
dependency on one for a single fade-in, and `motion-safe:` already gates it
|
|
523
|
+
on the user's reduced-motion setting.
|
|
524
|
+
--------------------------------------------------------------------------- */
|
|
525
|
+
@keyframes form-section-in {
|
|
526
|
+
from { opacity: 0; transform: translateY(16px); }
|
|
527
|
+
to { opacity: 1; transform: none; }
|
|
528
|
+
}
|