@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,136 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
import { ChevronDownIcon, Settings2Icon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils"
|
|
7
|
+
import type { ProjectFormSectionId, ProjectFormValue } from "./types"
|
|
8
|
+
|
|
9
|
+
export interface AdvancedDisclosureProps {
|
|
10
|
+
open: boolean
|
|
11
|
+
onOpenChange: (open: boolean) => void
|
|
12
|
+
label?: string
|
|
13
|
+
hint?: string
|
|
14
|
+
/**
|
|
15
|
+
* What's configured inside, shown while it's closed. Empty → a "nothing set"
|
|
16
|
+
* line. This is what makes folding the sections away safe rather than sneaky.
|
|
17
|
+
*/
|
|
18
|
+
summary?: string[]
|
|
19
|
+
children: ReactNode
|
|
20
|
+
className?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The fold that hides the sections most people never touch.
|
|
25
|
+
*
|
|
26
|
+
* Two rules make it honest. It **says what's inside while it's closed** — a
|
|
27
|
+
* summary of the configured items, not just a chevron — and the parent form
|
|
28
|
+
* **opens it on its own** when any of those sections already holds a value.
|
|
29
|
+
* Between them, no setting can ever be hidden behind a shut drawer: a fold that
|
|
30
|
+
* conceals configuration you didn't set yourself is how people end up debugging
|
|
31
|
+
* a worker for twenty minutes over a post-create command they can't see.
|
|
32
|
+
*/
|
|
33
|
+
export function AdvancedDisclosure({
|
|
34
|
+
open,
|
|
35
|
+
onOpenChange,
|
|
36
|
+
label = "Advanced options",
|
|
37
|
+
hint = "Features, extensions, lifecycle commands, secrets — the environment beyond the base image.",
|
|
38
|
+
summary = [],
|
|
39
|
+
children,
|
|
40
|
+
className,
|
|
41
|
+
}: AdvancedDisclosureProps) {
|
|
42
|
+
return (
|
|
43
|
+
<div className={cn("space-y-4", className)}>
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
onClick={() => onOpenChange(!open)}
|
|
47
|
+
aria-expanded={open}
|
|
48
|
+
data-slot="advanced-disclosure"
|
|
49
|
+
className={cn(
|
|
50
|
+
// `items-start`: on a phone the hint wraps to three or four lines, and
|
|
51
|
+
// a vertically-centred icon then floats halfway down a paragraph it's
|
|
52
|
+
// supposed to label. Padding follows the form's width, like the sections.
|
|
53
|
+
"flex w-full cursor-pointer items-start gap-3 rounded-xl border border-dashed px-4 py-4 text-left transition-colors @min-[30rem]/form:px-5",
|
|
54
|
+
open ? "border-border bg-muted/30" : "border-border hover:border-muted-foreground/40 hover:bg-muted/30"
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
|
58
|
+
<Settings2Icon className="h-4 w-4" />
|
|
59
|
+
</span>
|
|
60
|
+
|
|
61
|
+
<span className="min-w-0 flex-1">
|
|
62
|
+
<span className="block font-display text-base font-bold tracking-tight">{label}</span>
|
|
63
|
+
{/* While it's open the hint is redundant — the sections are right
|
|
64
|
+
there. While it's closed, the summary is the only trace of what
|
|
65
|
+
they hold, so it takes the line. */}
|
|
66
|
+
{open ? (
|
|
67
|
+
<span className="block text-xs text-muted-foreground">{hint}</span>
|
|
68
|
+
) : summary.length > 0 ? (
|
|
69
|
+
<span className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1">
|
|
70
|
+
{summary.map((item) => (
|
|
71
|
+
<span
|
|
72
|
+
key={item}
|
|
73
|
+
className="inline-flex items-center rounded border border-primary/25 bg-primary/5 px-1.5 py-0.5 font-mono text-[10px] leading-none text-primary"
|
|
74
|
+
>
|
|
75
|
+
{item}
|
|
76
|
+
</span>
|
|
77
|
+
))}
|
|
78
|
+
</span>
|
|
79
|
+
) : (
|
|
80
|
+
// Clamped: closed and empty, this is a caption, and four lines of it
|
|
81
|
+
// on a phone is longer than the control it describes.
|
|
82
|
+
<span className="line-clamp-2 block text-xs text-muted-foreground">{hint}</span>
|
|
83
|
+
)}
|
|
84
|
+
</span>
|
|
85
|
+
|
|
86
|
+
<ChevronDownIcon
|
|
87
|
+
className={cn("mt-2 h-4 w-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")}
|
|
88
|
+
aria-hidden
|
|
89
|
+
/>
|
|
90
|
+
</button>
|
|
91
|
+
|
|
92
|
+
{/* Unmounted rather than hidden: these sections carry inputs, and a
|
|
93
|
+
`display:none` fieldset still tab-focuses and still validates. */}
|
|
94
|
+
{open && children}
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One short label per advanced section that holds something — `2 features`,
|
|
101
|
+
* `lifecycle`, `3 secrets`. Pure, and exported, because a caller rendering its
|
|
102
|
+
* own fold needs the same sentence.
|
|
103
|
+
*/
|
|
104
|
+
export function advancedSummary(value: ProjectFormValue, sections: ProjectFormSectionId[]): string[] {
|
|
105
|
+
const out: string[] = []
|
|
106
|
+
for (const id of sections) {
|
|
107
|
+
switch (id) {
|
|
108
|
+
case "features":
|
|
109
|
+
if (value.features.length) out.push(`${value.features.length} feature${value.features.length > 1 ? "s" : ""}`)
|
|
110
|
+
break
|
|
111
|
+
case "extensions":
|
|
112
|
+
if (value.vscodeExtensions.length)
|
|
113
|
+
out.push(`${value.vscodeExtensions.length} extension${value.vscodeExtensions.length > 1 ? "s" : ""}`)
|
|
114
|
+
break
|
|
115
|
+
case "lifecycle":
|
|
116
|
+
if (value.postCreateCommand || value.postStartCommand) out.push("lifecycle")
|
|
117
|
+
break
|
|
118
|
+
case "ports":
|
|
119
|
+
if (value.forwardPorts.length) out.push(`ports ${value.forwardPorts.join(", ")}`)
|
|
120
|
+
break
|
|
121
|
+
case "prewarm":
|
|
122
|
+
if (value.prewarmImages.length) out.push(`${value.prewarmImages.length} prewarm`)
|
|
123
|
+
break
|
|
124
|
+
case "docker":
|
|
125
|
+
if (value.dockerInDocker) out.push("docker-in-docker")
|
|
126
|
+
break
|
|
127
|
+
case "secrets":
|
|
128
|
+
if (value.secrets.length) out.push(`${value.secrets.length} secret${value.secrets.length > 1 ? "s" : ""}`)
|
|
129
|
+
break
|
|
130
|
+
// Identity, image and repositories are never advanced — they're the form.
|
|
131
|
+
default:
|
|
132
|
+
break
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return out
|
|
136
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
import { CheckIcon, TerminalIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils"
|
|
7
|
+
import { Button } from "../button"
|
|
8
|
+
|
|
9
|
+
export interface ManifestRow {
|
|
10
|
+
label: string
|
|
11
|
+
value: string
|
|
12
|
+
/** Drawn as ticked. "Filled in", not "valid" — the form doesn't grade you. */
|
|
13
|
+
done?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BuildManifestProps {
|
|
17
|
+
rows: ManifestRow[]
|
|
18
|
+
/** Rendered as the panel's primary action. Its `type="submit"` reaches the enclosing form. */
|
|
19
|
+
submitLabel?: ReactNode
|
|
20
|
+
submittingLabel?: ReactNode
|
|
21
|
+
submitting?: boolean
|
|
22
|
+
disabled?: boolean
|
|
23
|
+
/** Error banner above the button — a failed mutation, typically. */
|
|
24
|
+
error?: string | null
|
|
25
|
+
/** Small print under the button. */
|
|
26
|
+
caption?: ReactNode
|
|
27
|
+
/** Anything after the caption (an "or start from a template" link). */
|
|
28
|
+
footer?: ReactNode
|
|
29
|
+
title?: string
|
|
30
|
+
className?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The live recap of a long form, as a terminal-ish panel: one line per section,
|
|
35
|
+
* ticked as you fill it, then the submit button.
|
|
36
|
+
*
|
|
37
|
+
* It's a **mirror, not a source** — it reads values the form already owns and
|
|
38
|
+
* never writes any. That's what lets it be sticky next to a seven-section form
|
|
39
|
+
* without becoming a second copy of the state, and what lets an app drop it
|
|
40
|
+
* entirely (`manifest={false}`) and put its own footer instead.
|
|
41
|
+
*/
|
|
42
|
+
export function BuildManifest({
|
|
43
|
+
rows,
|
|
44
|
+
submitLabel = "Save",
|
|
45
|
+
submittingLabel = "Saving…",
|
|
46
|
+
submitting = false,
|
|
47
|
+
disabled = false,
|
|
48
|
+
error,
|
|
49
|
+
caption,
|
|
50
|
+
footer,
|
|
51
|
+
title = "build manifest",
|
|
52
|
+
className,
|
|
53
|
+
}: BuildManifestProps) {
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
data-slot="build-manifest"
|
|
57
|
+
className={cn("overflow-hidden rounded-xl border border-border bg-card", className)}
|
|
58
|
+
>
|
|
59
|
+
{/* Window chrome in the three pillar colours — the same "this is a machine
|
|
60
|
+
artefact" cue as TerminalPanel, so the recap reads as the manifest a
|
|
61
|
+
worker will be built from rather than as a form sidebar. */}
|
|
62
|
+
<div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2.5">
|
|
63
|
+
<div className="flex gap-1.5">
|
|
64
|
+
<span className="h-2.5 w-2.5 rounded-full bg-run/70" />
|
|
65
|
+
<span className="h-2.5 w-2.5 rounded-full bg-build/70" />
|
|
66
|
+
<span className="h-2.5 w-2.5 rounded-full bg-ship/70" />
|
|
67
|
+
</div>
|
|
68
|
+
<div className="ml-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
|
69
|
+
<TerminalIcon className="h-3.5 w-3.5" />
|
|
70
|
+
{title}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<div className="space-y-4 p-5">
|
|
75
|
+
<dl className="space-y-2.5">
|
|
76
|
+
{rows.map((row) => (
|
|
77
|
+
<div key={row.label} className="flex items-center gap-3 text-sm">
|
|
78
|
+
<span
|
|
79
|
+
className={cn(
|
|
80
|
+
"flex h-4 w-4 shrink-0 items-center justify-center rounded-full border transition-colors",
|
|
81
|
+
row.done ? "border-primary bg-primary text-background" : "border-border text-transparent"
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
<CheckIcon className="h-2.5 w-2.5" strokeWidth={3} />
|
|
85
|
+
</span>
|
|
86
|
+
<dt className="w-16 shrink-0 font-mono text-[11px] tracking-wide text-muted-foreground uppercase">
|
|
87
|
+
{row.label}
|
|
88
|
+
</dt>
|
|
89
|
+
<dd className="min-w-0 flex-1 truncate text-right font-medium">{row.value}</dd>
|
|
90
|
+
</div>
|
|
91
|
+
))}
|
|
92
|
+
</dl>
|
|
93
|
+
|
|
94
|
+
{error && (
|
|
95
|
+
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
96
|
+
{error}
|
|
97
|
+
</p>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
<Button type="submit" size="lg" disabled={submitting || disabled} className="group w-full">
|
|
101
|
+
{submitting ? (
|
|
102
|
+
<span className="flex items-center gap-2">
|
|
103
|
+
<span className="h-4 w-4 animate-spin rounded-full border-2 border-background/40 border-t-background" />
|
|
104
|
+
{submittingLabel}
|
|
105
|
+
</span>
|
|
106
|
+
) : (
|
|
107
|
+
submitLabel
|
|
108
|
+
)}
|
|
109
|
+
</Button>
|
|
110
|
+
|
|
111
|
+
{caption && <p className="text-center text-[11px] text-muted-foreground">{caption}</p>}
|
|
112
|
+
{footer}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react"
|
|
4
|
+
import { Loader2Icon, SearchIcon, XIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils"
|
|
7
|
+
import { ExtensionCard, ExtensionCardSkeleton } from "../devcontainer/extension-card"
|
|
8
|
+
import { extensionId } from "../devcontainer/registry"
|
|
9
|
+
import type { VscodeExtensionEntry } from "../devcontainer/types"
|
|
10
|
+
import { Input } from "../input"
|
|
11
|
+
|
|
12
|
+
export interface ExtensionPickerProps {
|
|
13
|
+
/** The ids a project stores — `publisher.name`. */
|
|
14
|
+
value: string[]
|
|
15
|
+
onChange: (value: string[]) => void
|
|
16
|
+
/**
|
|
17
|
+
* Searches a marketplace. A callback, not a fetch: the package never talks to
|
|
18
|
+
* open-vsx.org itself, and an app without one still gets a working picker
|
|
19
|
+
* where ids are typed by hand.
|
|
20
|
+
*/
|
|
21
|
+
onSearch?: (query: string) => Promise<VscodeExtensionEntry[]>
|
|
22
|
+
/** Debounce before firing `onSearch`, ms. */
|
|
23
|
+
debounceMs?: number
|
|
24
|
+
className?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Pick the VS Code extensions installed into code-server: a marketplace search
|
|
29
|
+
* rendering `ExtensionCard`s, plus the picked list.
|
|
30
|
+
*
|
|
31
|
+
* The payloads of everything picked this session are kept around, so a selected
|
|
32
|
+
* extension keeps its icon and publisher in the list below instead of collapsing
|
|
33
|
+
* back to a bare id the moment the results are cleared — the project only ever
|
|
34
|
+
* stores the id, but the screen doesn't have to look like it.
|
|
35
|
+
*/
|
|
36
|
+
export function ExtensionPicker({ value, onChange, onSearch, debounceMs = 300, className }: ExtensionPickerProps) {
|
|
37
|
+
const [query, setQuery] = useState("")
|
|
38
|
+
const [results, setResults] = useState<VscodeExtensionEntry[]>([])
|
|
39
|
+
const [known, setKnown] = useState<Record<string, VscodeExtensionEntry>>({})
|
|
40
|
+
const [loading, setLoading] = useState(false)
|
|
41
|
+
const [open, setOpen] = useState(false)
|
|
42
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
43
|
+
// Guards against an earlier, slower search landing after a later one and
|
|
44
|
+
// overwriting its results — the classic out-of-order autocomplete bug.
|
|
45
|
+
const runId = useRef(0)
|
|
46
|
+
|
|
47
|
+
useEffect(() => () => { if (timer.current) clearTimeout(timer.current) }, [])
|
|
48
|
+
|
|
49
|
+
function handleQuery(next: string) {
|
|
50
|
+
setQuery(next)
|
|
51
|
+
if (timer.current) clearTimeout(timer.current)
|
|
52
|
+
if (!next.trim() || !onSearch) {
|
|
53
|
+
setResults([])
|
|
54
|
+
setOpen(false)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
setLoading(true)
|
|
58
|
+
setOpen(true)
|
|
59
|
+
const run = ++runId.current
|
|
60
|
+
timer.current = setTimeout(async () => {
|
|
61
|
+
let found: VscodeExtensionEntry[] = []
|
|
62
|
+
try {
|
|
63
|
+
found = await onSearch(next)
|
|
64
|
+
} catch {
|
|
65
|
+
found = []
|
|
66
|
+
}
|
|
67
|
+
if (run !== runId.current) return
|
|
68
|
+
setResults(found)
|
|
69
|
+
setLoading(false)
|
|
70
|
+
}, debounceMs)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function add(extension: VscodeExtensionEntry) {
|
|
74
|
+
const id = extensionId(extension)
|
|
75
|
+
if (!id) return
|
|
76
|
+
setKnown((prev) => ({ ...prev, [id]: extension }))
|
|
77
|
+
if (!value.includes(id)) onChange([...value, id])
|
|
78
|
+
reset()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function addTyped() {
|
|
82
|
+
const id = query.trim()
|
|
83
|
+
if (!id || value.includes(id)) return
|
|
84
|
+
onChange([...value, id])
|
|
85
|
+
reset()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function reset() {
|
|
89
|
+
setQuery("")
|
|
90
|
+
setResults([])
|
|
91
|
+
setOpen(false)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className={cn("space-y-2", className)}>
|
|
96
|
+
<div>
|
|
97
|
+
<div className="flex items-center gap-2 rounded-md border border-border bg-transparent px-3 py-1.5">
|
|
98
|
+
<SearchIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
99
|
+
<input
|
|
100
|
+
type="text"
|
|
101
|
+
value={query}
|
|
102
|
+
onChange={(e) => handleQuery(e.target.value)}
|
|
103
|
+
onKeyDown={(e) => {
|
|
104
|
+
if (e.key === "Enter") { e.preventDefault(); addTyped() }
|
|
105
|
+
if (e.key === "Escape") setOpen(false)
|
|
106
|
+
}}
|
|
107
|
+
onFocus={() => results.length > 0 && setOpen(true)}
|
|
108
|
+
placeholder={onSearch ? "Search extensions or type an ID…" : "Type an extension ID…"}
|
|
109
|
+
aria-label="Extension search"
|
|
110
|
+
className="flex-1 bg-transparent text-sm placeholder:text-muted-foreground focus:outline-none"
|
|
111
|
+
/>
|
|
112
|
+
{loading && <Loader2Icon className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />}
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{/* Results in the flow rather than in a popover: this sits inside a
|
|
116
|
+
section card that clips its overflow, and a floating list gets cut
|
|
117
|
+
at the card's bottom edge. Pushing the form down costs nothing. */}
|
|
118
|
+
{open && (results.length > 0 || loading) && (
|
|
119
|
+
<div className="mt-1 space-y-1 rounded-md border border-border bg-muted/20 p-1">
|
|
120
|
+
{loading && results.length === 0
|
|
121
|
+
? [0, 1, 2].map((i) => <ExtensionCardSkeleton key={i} />)
|
|
122
|
+
: results.map((extension) => {
|
|
123
|
+
const id = extensionId(extension)
|
|
124
|
+
const already = value.includes(id)
|
|
125
|
+
return (
|
|
126
|
+
<ExtensionCard
|
|
127
|
+
key={id}
|
|
128
|
+
extension={extension}
|
|
129
|
+
selected={already}
|
|
130
|
+
disabled={already}
|
|
131
|
+
onSelect={() => add(extension)}
|
|
132
|
+
className="border-transparent bg-card"
|
|
133
|
+
/>
|
|
134
|
+
)
|
|
135
|
+
})}
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
{value.length > 0 && (
|
|
141
|
+
<div className="space-y-1.5">
|
|
142
|
+
{value.map((id) => (
|
|
143
|
+
<ExtensionCard
|
|
144
|
+
key={id}
|
|
145
|
+
extension={known[id] ?? { id }}
|
|
146
|
+
custom={!known[id] && !id.includes(".")}
|
|
147
|
+
action={
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
aria-label={`Remove ${id}`}
|
|
151
|
+
onClick={() => onChange(value.filter((e) => e !== id))}
|
|
152
|
+
className="cursor-pointer text-muted-foreground transition-colors hover:text-foreground"
|
|
153
|
+
>
|
|
154
|
+
<XIcon className="h-3.5 w-3.5" />
|
|
155
|
+
</button>
|
|
156
|
+
}
|
|
157
|
+
/>
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
|
|
162
|
+
{value.length > 0 && (
|
|
163
|
+
<p className="text-xs text-muted-foreground">
|
|
164
|
+
{value.length} extension{value.length > 1 ? "s" : ""} — installed at worker startup via code-server
|
|
165
|
+
</p>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import { PlusIcon, XIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils"
|
|
7
|
+
import { FeatureCard } from "../devcontainer/feature-card"
|
|
8
|
+
import { FeatureTile } from "../devcontainer/feature-tile"
|
|
9
|
+
import { parseRegistryRef } from "../devcontainer/registry"
|
|
10
|
+
import type { DevcontainerFeatureEntry } from "../devcontainer/types"
|
|
11
|
+
import { Button } from "../button"
|
|
12
|
+
import { Input } from "../input"
|
|
13
|
+
import type { ProjectFeatureSelection } from "./types"
|
|
14
|
+
|
|
15
|
+
export interface FeaturePickerProps {
|
|
16
|
+
features?: DevcontainerFeatureEntry[]
|
|
17
|
+
value: ProjectFeatureSelection[]
|
|
18
|
+
onChange: (value: ProjectFeatureSelection[]) => void
|
|
19
|
+
/**
|
|
20
|
+
* `chips` (default) — the catalog as tag-sized entries that flow and wrap,
|
|
21
|
+
* with the cards of what you picked beside them. `cards` — every entry as a
|
|
22
|
+
* full card, which only stays readable while the catalog is short.
|
|
23
|
+
*/
|
|
24
|
+
layout?: "chips" | "cards"
|
|
25
|
+
/** Where the picked cards go. `auto` splits above ~52rem. */
|
|
26
|
+
detail?: "auto" | "side" | "below"
|
|
27
|
+
className?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Pick the devcontainer features: the catalog on one side, what you've composed
|
|
32
|
+
* on the other.
|
|
33
|
+
*
|
|
34
|
+
* Same treatment as `ImagePicker`, with one structural difference — features are
|
|
35
|
+
* **multi-select**, so the panel can't be "the card of the selected one". It's
|
|
36
|
+
* the list of everything picked, in install order, each with its option chips,
|
|
37
|
+
* its author and its OCI reference, and a cross to drop it. Hand-typed refs land
|
|
38
|
+
* in that same list rather than in a second one underneath, because from the
|
|
39
|
+
* user's side they're the same thing: a feature that will be installed.
|
|
40
|
+
*
|
|
41
|
+
* Selection order is preserved — features install in the order they're listed,
|
|
42
|
+
* so a `Set` would quietly lose information here.
|
|
43
|
+
*/
|
|
44
|
+
export function FeaturePicker({
|
|
45
|
+
features = [],
|
|
46
|
+
value,
|
|
47
|
+
onChange,
|
|
48
|
+
layout = "chips",
|
|
49
|
+
detail: detailPosition = "auto",
|
|
50
|
+
className,
|
|
51
|
+
}: FeaturePickerProps) {
|
|
52
|
+
const [customInput, setCustomInput] = useState("")
|
|
53
|
+
|
|
54
|
+
const isSelected = (id?: string) => !!id && value.some((f) => f.id === id)
|
|
55
|
+
const catalogById = new Map(features.filter((f) => f.id).map((f) => [f.id!, f]))
|
|
56
|
+
|
|
57
|
+
function toggle(feature: DevcontainerFeatureEntry) {
|
|
58
|
+
const id = feature.id
|
|
59
|
+
if (!id) return
|
|
60
|
+
onChange(isSelected(id) ? value.filter((f) => f.id !== id) : [...value, { id }])
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function addCustom() {
|
|
64
|
+
const ociRef = customInput.trim()
|
|
65
|
+
if (!ociRef || value.some((f) => f.ociRef === ociRef)) return
|
|
66
|
+
// Same id shape the dashboard has always written, so an existing project
|
|
67
|
+
// round-trips unchanged: "ghcr.io/foo/features/mytool:1" → "custom-mytool".
|
|
68
|
+
const id = `custom-${parseRegistryRef(ociRef).name ?? ociRef}`
|
|
69
|
+
onChange([...value, { id, ociRef }])
|
|
70
|
+
setCustomInput("")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const customField = (
|
|
74
|
+
<div className="flex gap-2">
|
|
75
|
+
<Input
|
|
76
|
+
value={customInput}
|
|
77
|
+
aria-label="Custom OCI reference"
|
|
78
|
+
placeholder="Custom OCI ref, e.g. ghcr.io/foo/features/mytool:1"
|
|
79
|
+
className="flex-1"
|
|
80
|
+
onChange={(e) => setCustomInput(e.target.value)}
|
|
81
|
+
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addCustom())}
|
|
82
|
+
/>
|
|
83
|
+
<Button type="button" variant="outline" onClick={addCustom}>
|
|
84
|
+
<PlusIcon className="mr-1 h-3.5 w-3.5" />
|
|
85
|
+
Add
|
|
86
|
+
</Button>
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
/** One picked feature — catalog entry or hand-typed ref — with its remove cross. */
|
|
91
|
+
const picked = value.map((selection) => {
|
|
92
|
+
const entry = catalogById.get(selection.id)
|
|
93
|
+
return (
|
|
94
|
+
<FeatureCard
|
|
95
|
+
key={selection.id}
|
|
96
|
+
feature={entry ?? { id: selection.id, ociRef: selection.ociRef }}
|
|
97
|
+
custom={!entry}
|
|
98
|
+
copyable
|
|
99
|
+
options={selection.options}
|
|
100
|
+
action={
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
aria-label={`Remove ${entry?.label ?? selection.ociRef ?? selection.id}`}
|
|
104
|
+
onClick={() => onChange(value.filter((f) => f.id !== selection.id))}
|
|
105
|
+
className="cursor-pointer text-muted-foreground transition-colors hover:text-foreground"
|
|
106
|
+
>
|
|
107
|
+
<XIcon className="h-3.5 w-3.5" />
|
|
108
|
+
</button>
|
|
109
|
+
}
|
|
110
|
+
/>
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
if (layout === "cards") {
|
|
115
|
+
return (
|
|
116
|
+
<div className={cn("@container/picker space-y-2", className)}>
|
|
117
|
+
<div className="grid grid-cols-1 gap-2 @min-[34rem]/picker:grid-cols-2">
|
|
118
|
+
{features.map((feature) => (
|
|
119
|
+
<FeatureCard
|
|
120
|
+
key={feature.id ?? feature.ociRef}
|
|
121
|
+
feature={feature}
|
|
122
|
+
copyable
|
|
123
|
+
options={value.find((f) => f.id === feature.id)?.options}
|
|
124
|
+
selected={isSelected(feature.id)}
|
|
125
|
+
onSelect={() => toggle(feature)}
|
|
126
|
+
/>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
{customField}
|
|
130
|
+
{value.length > 0 && <Count n={value.length} />}
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A CONTAINER query, so the split depends on the picker's own width and never
|
|
136
|
+
// on the window's. Stacked is the same reading order, just vertical.
|
|
137
|
+
const split = {
|
|
138
|
+
auto: "@min-[52rem]/picker:grid-cols-[1fr_minmax(18rem,22rem)] @min-[52rem]/picker:items-start",
|
|
139
|
+
// Even "forced" has a floor: below ~34rem two columns simply don't fit, and
|
|
140
|
+
// the panel would push the page into horizontal scrolling. `side` means "as
|
|
141
|
+
// soon as it fits", not "always".
|
|
142
|
+
side: "@min-[34rem]/picker:grid-cols-[1fr_minmax(15rem,22rem)] @min-[34rem]/picker:items-start",
|
|
143
|
+
below: "",
|
|
144
|
+
}[detailPosition]
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div className={cn("@container/picker", className)}>
|
|
148
|
+
<div className={cn("grid gap-3", split)}>
|
|
149
|
+
<div className="space-y-3">
|
|
150
|
+
<div className="flex flex-wrap content-start gap-1.5">
|
|
151
|
+
{features.map((feature) => (
|
|
152
|
+
<FeatureTile
|
|
153
|
+
key={feature.id ?? feature.ociRef}
|
|
154
|
+
feature={feature}
|
|
155
|
+
selected={isSelected(feature.id)}
|
|
156
|
+
onSelect={() => toggle(feature)}
|
|
157
|
+
/>
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
{customField}
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div className="space-y-1.5">
|
|
164
|
+
{picked.length > 0 ? (
|
|
165
|
+
<>
|
|
166
|
+
{picked}
|
|
167
|
+
<Count n={value.length} />
|
|
168
|
+
</>
|
|
169
|
+
) : (
|
|
170
|
+
// A placeholder rather than nothing: without it the whole section
|
|
171
|
+
// reflows on the first pick and jumps under the cursor.
|
|
172
|
+
<div
|
|
173
|
+
className={cn(
|
|
174
|
+
"min-h-[7.5rem] items-center justify-center rounded-lg border border-dashed border-border p-4 text-center text-xs text-muted-foreground",
|
|
175
|
+
detailPosition === "side" ? "hidden @min-[34rem]/picker:flex" : "hidden @min-[52rem]/picker:flex"
|
|
176
|
+
)}
|
|
177
|
+
>
|
|
178
|
+
Nothing selected — the base image ships as-is.
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function Count({ n }: { n: number }) {
|
|
188
|
+
return (
|
|
189
|
+
<p className="text-xs text-muted-foreground">
|
|
190
|
+
{n} feature{n > 1 ? "s" : ""} selected — installed at worker startup
|
|
191
|
+
</p>
|
|
192
|
+
)
|
|
193
|
+
}
|