@spunto/design-system 0.19.0 → 0.21.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 +50 -4
- package/package.json +1 -1
- package/src/components/action-menu.tsx +225 -0
- package/src/components/projects/build-steps.tsx +213 -0
- package/src/components/projects/index.ts +7 -0
- package/src/components/workers/index.ts +6 -0
- package/src/components/workers/worker-card.tsx +39 -7
- package/src/index.ts +7 -0
package/README.md
CHANGED
|
@@ -66,6 +66,21 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
|
|
|
66
66
|
(a confirmation variant, non-dismissible), `Sheet`, `Tooltip`. Their portals render
|
|
67
67
|
into the provider's overlay container; `Tooltip`'s shared delay group is mounted by
|
|
68
68
|
the provider too.
|
|
69
|
+
- _ActionMenu_ — `ActionMenu` (+`ActionMenuItem`, `ActionMenuEntry`), the `⋯` menu
|
|
70
|
+
**described instead of assembled**: `items` is a list of `{ label, icon?, onClick }`
|
|
71
|
+
(or `href`, for a real `<a>` via Base UI's `Menu.LinkItem`) and the component builds
|
|
72
|
+
the trigger, the portal, the popup, the items and their states — `loading` swaps the
|
|
73
|
+
icon for a spinner and disarms the entry, `destructive` turns it red, `shortcut`
|
|
74
|
+
right-aligns a `Kbd`. A menu is the one place an app brings nothing but data, and
|
|
75
|
+
every app was re-typing the same twenty lines of Base UI markup around it. Two things
|
|
76
|
+
the data form gets right that hand-written menus don't: entries may be
|
|
77
|
+
`false`/`null`/`undefined`, so a conditional menu stays one flat array; and
|
|
78
|
+
`"separator"` is an entry like any other, **normalized** — a rule left leading,
|
|
79
|
+
trailing or doubled by a missing neighbour disappears instead of floating at the top
|
|
80
|
+
of the popup. Empty after that pass → nothing renders, not even the `⋯`. Still
|
|
81
|
+
presentational: it never knows what an entry *does*. For what a list can't say (a
|
|
82
|
+
submenu, a checkbox item), compose Base UI directly — that's what `WorkerCard`'s
|
|
83
|
+
`actions` slot is for.
|
|
69
84
|
- _Sheet_ — `Sheet` (+`SheetTrigger`/`SheetClose`/`SheetContent`/`SheetHeader`/
|
|
70
85
|
`SheetFooter`/`SheetTitle`/`SheetDescription`), the same Base UI dialog entering from
|
|
71
86
|
an edge. `side` (4) decides the edge, `size` (`sm`…`full`) how far it comes in — two
|
|
@@ -167,10 +182,18 @@ opinions a dashboard doesn't want, so it gets an entry too.
|
|
|
167
182
|
- **`WorkerCard`** — a Spunto worker as a card: state, author, node, setup
|
|
168
183
|
progress, git branches, resources, banners, actions. **Purely presentational** —
|
|
169
184
|
hand it a snapshot plus slots and it draws. The rule that keeps it that way:
|
|
170
|
-
*everything that CALLS the API is a slot or a callback; everything that
|
|
171
|
-
in the component.* So the `⋯`
|
|
172
|
-
(`href` + `render.link`, never `next/link`), the rebuild
|
|
173
|
-
tag persistence (`onAddTag`/`onRemoveTag`) all stay in the
|
|
185
|
+
*everything that CALLS the API is data, a slot or a callback; everything that
|
|
186
|
+
DRAWS is in the component.* So what the `⋯` entries **do** (`menu`), the footer
|
|
187
|
+
(`footer`), the link (`href` + `render.link`, never `next/link`), the rebuild
|
|
188
|
+
(`onRebuild`) and the tag persistence (`onAddTag`/`onRemoveTag`) all stay in the
|
|
189
|
+
app.
|
|
190
|
+
- **The `⋯` menu is a prop, not markup.** `menu` takes the `ActionMenuEntry[]` of
|
|
191
|
+
the `ActionMenu` primitive — `{ label, icon?, onClick }`, falsy entries allowed,
|
|
192
|
+
`"separator"` normalized — and the card draws the whole menu. Before, every app
|
|
193
|
+
handed the card a fully-built menu through the `actions` slot and re-typed the
|
|
194
|
+
same Base UI markup to do it. `actions` stays as the escape hatch (a submenu, a
|
|
195
|
+
checkbox item) and wins over `menu` when both are given; `render.menuLink` is
|
|
196
|
+
the link slot for an entry with an `href`.
|
|
174
197
|
- **Its bricks, exported individually** because the table view assembles them
|
|
175
198
|
differently: `WorkerStatusDot`/`WorkerStatusPill` (+`resolveWorkerStatus`,
|
|
176
199
|
`workerStatusConfig`), `SetupProgress` (+`setupProgress`, `phaseLabel`,
|
|
@@ -309,6 +332,29 @@ And the read-only twin of that form:
|
|
|
309
332
|
- **Container queries** here too: the panel is 288 px in a sidebar on a 27" screen
|
|
310
333
|
and full width on a phone — and the desktop case is the narrow one.
|
|
311
334
|
|
|
335
|
+
And the build that configuration triggers, while it runs:
|
|
336
|
+
|
|
337
|
+
- **`BuildSteps`** — an image build as the **list of blocks it is made of**
|
|
338
|
+
(base image, bootstrap, one per devcontainer feature, VS Code extensions,
|
|
339
|
+
finalize, DinD seed) rather than a wall of log. Each row lights up in turn and
|
|
340
|
+
carries its own duration, so "what is it doing, and for how long" reads at a
|
|
341
|
+
glance and a slow feature is visibly *the* slow one.
|
|
342
|
+
- **The whole plan is drawn greyed out from the first frame**, instead of rows
|
|
343
|
+
appearing one by one: the list doubles as a table of contents for what the
|
|
344
|
+
image contains, the `done/total` counter has a denominator immediately, and
|
|
345
|
+
nothing shifts under the cursor for the four minutes of a build. A block
|
|
346
|
+
*absent* from the plan isn't `skipped` — `skipped` is a block that was reached
|
|
347
|
+
and had nothing to do.
|
|
348
|
+
- **Presentational, so it isn't only about Docker**: it knows no WebSocket, no
|
|
349
|
+
marker, no daemon — hand it a snapshot of timestamped blocks and it draws.
|
|
350
|
+
Anything that can describe itself as ordered blocks (a CI pipeline, a
|
|
351
|
+
migration, a multi-phase command) can feed it.
|
|
352
|
+
- **Timestamps in, durations out.** The running block counts from `startedAt`
|
|
353
|
+
itself, every second — a duration computed app-side freezes between two
|
|
354
|
+
snapshots and reads as a stuck build. Under a minute the package's
|
|
355
|
+
`formatDuration` is reused (one formatter, no drift); past it the format
|
|
356
|
+
switches to `2m 13s`.
|
|
357
|
+
|
|
312
358
|
## Marketing — `/marketing`
|
|
313
359
|
|
|
314
360
|
```tsx
|
package/package.json
CHANGED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ComponentType, ReactElement, ReactNode } from "react"
|
|
4
|
+
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
|
5
|
+
import { ExternalLinkIcon, MoreVerticalIcon } from "lucide-react"
|
|
6
|
+
|
|
7
|
+
import { cn } from "../utils"
|
|
8
|
+
import { Spinner } from "./spinner"
|
|
9
|
+
import { useOverlayContainer } from "./spunto-provider"
|
|
10
|
+
|
|
11
|
+
/** One entry of an `ActionMenu`, described rather than rendered. */
|
|
12
|
+
export interface ActionMenuItem {
|
|
13
|
+
/** What the entry says. */
|
|
14
|
+
label: ReactNode
|
|
15
|
+
/**
|
|
16
|
+
* React key. Defaults to the label when it's a string — give one explicitly if
|
|
17
|
+
* the label is a node, or if two entries share a label.
|
|
18
|
+
*/
|
|
19
|
+
key?: string
|
|
20
|
+
/** Leading icon — any component taking a `className` (lucide-react, a local SVG…). */
|
|
21
|
+
icon?: ComponentType<{ className?: string }>
|
|
22
|
+
/** What the entry does. Omitted (and no `href`) → the entry is inert. */
|
|
23
|
+
onClick?: () => void
|
|
24
|
+
/** Navigates instead of acting: the entry renders as a real `<a>` (middle-click, ⌘-click…). */
|
|
25
|
+
href?: string
|
|
26
|
+
/** `"_blank"` implies the trailing ↗ unless `external` says otherwise. */
|
|
27
|
+
target?: string
|
|
28
|
+
/** Trailing ↗. Defaults to `target === "_blank"`. */
|
|
29
|
+
external?: boolean
|
|
30
|
+
disabled?: boolean
|
|
31
|
+
/**
|
|
32
|
+
* Action in flight: a spinner takes the icon's place and the entry is
|
|
33
|
+
* disabled. Kept apart from `disabled` so the caller doesn't have to swap the
|
|
34
|
+
* icon itself — the pending flag of a mutation goes straight in here.
|
|
35
|
+
*/
|
|
36
|
+
loading?: boolean
|
|
37
|
+
/** Red, and pushed below a separator by convention (the caller still places it). */
|
|
38
|
+
destructive?: boolean
|
|
39
|
+
/** Trailing hint, right-aligned — a `Kbd`, a shortcut, a count. */
|
|
40
|
+
shortcut?: ReactNode
|
|
41
|
+
/** Keeps the popup open after a click (a toggle, typically). */
|
|
42
|
+
closeOnClick?: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* An entry, a rule between groups, or nothing.
|
|
47
|
+
*
|
|
48
|
+
* The falsy members are the point: a menu is almost always conditional
|
|
49
|
+
* (`isRunning && { label: "Stop", … }`), and allowing `false`/`null`/`undefined`
|
|
50
|
+
* keeps that written as one flat array instead of a `filter(Boolean)` dance.
|
|
51
|
+
* Separators are normalized afterwards — see `normalizeEntries`.
|
|
52
|
+
*/
|
|
53
|
+
export type ActionMenuEntry = ActionMenuItem | "separator" | false | null | undefined
|
|
54
|
+
|
|
55
|
+
/** Renders an `href` entry with the app's link component (`next/link`, a router link…). */
|
|
56
|
+
export type ActionMenuLinkRender = (props: {
|
|
57
|
+
href: string
|
|
58
|
+
target?: string
|
|
59
|
+
children?: ReactNode
|
|
60
|
+
}) => ReactElement
|
|
61
|
+
|
|
62
|
+
export interface ActionMenuProps {
|
|
63
|
+
/** The menu, as data. Falsy entries are dropped; empty → nothing renders at all. */
|
|
64
|
+
items: ActionMenuEntry[]
|
|
65
|
+
/** Accessible name of the default `⋯` trigger. */
|
|
66
|
+
label?: string
|
|
67
|
+
/** Replaces the `⋯` button. Base UI merges its trigger props into this element. */
|
|
68
|
+
trigger?: ReactElement
|
|
69
|
+
triggerClassName?: string
|
|
70
|
+
/** Classes for the popup (its width, typically). */
|
|
71
|
+
className?: string
|
|
72
|
+
align?: "start" | "center" | "end"
|
|
73
|
+
side?: "top" | "bottom" | "left" | "right"
|
|
74
|
+
sideOffset?: number
|
|
75
|
+
/** Escape hatch for `href` entries; without it they render as a plain `<a>`. */
|
|
76
|
+
render?: { link?: ActionMenuLinkRender }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The `⋯` menu, described instead of assembled: give it a list of
|
|
81
|
+
* `{ label, icon, onClick }` and it builds the trigger, the popup, the items,
|
|
82
|
+
* the separators and their states.
|
|
83
|
+
*
|
|
84
|
+
* Every app was rebuilding the same twenty lines of Base UI markup around the
|
|
85
|
+
* same five shapes of entry (an action, a link, a destructive one, one with a
|
|
86
|
+
* mutation in flight, a rule between groups) — and drifting: different paddings,
|
|
87
|
+
* different red, a spinner here and not there. The data form is what the callers
|
|
88
|
+
* actually have anyway; the markup is ours.
|
|
89
|
+
*
|
|
90
|
+
* Two things it does that hand-written menus keep getting wrong:
|
|
91
|
+
*
|
|
92
|
+
* - **Conditional entries.** `false`/`null`/`undefined` are legal entries, so a
|
|
93
|
+
* menu stays one flat array (`isRunning && { label: "Stop", … }`).
|
|
94
|
+
* - **Separators are normalized.** Leading, trailing and consecutive rules are
|
|
95
|
+
* dropped, so a `"separator"` next to an entry that just disappeared doesn't
|
|
96
|
+
* leave a stray line floating at the top of the popup. That's the bug you get
|
|
97
|
+
* every time a conditional item sits next to a conditional separator.
|
|
98
|
+
*
|
|
99
|
+
* It stays presentational: it never knows what an entry *does*. `onClick` and
|
|
100
|
+
* `href` come from the caller, like everywhere else in this package.
|
|
101
|
+
*/
|
|
102
|
+
export function ActionMenu({
|
|
103
|
+
items,
|
|
104
|
+
label = "Actions",
|
|
105
|
+
trigger,
|
|
106
|
+
triggerClassName,
|
|
107
|
+
className,
|
|
108
|
+
align = "end",
|
|
109
|
+
side = "bottom",
|
|
110
|
+
sideOffset = 4,
|
|
111
|
+
render,
|
|
112
|
+
}: ActionMenuProps) {
|
|
113
|
+
const container = useOverlayContainer()
|
|
114
|
+
const entries = normalizeEntries(items)
|
|
115
|
+
|
|
116
|
+
// No action → no affordance. A `⋯` that opens an empty popup is worse than no
|
|
117
|
+
// `⋯` at all, and callers build these lists conditionally: an empty one is the
|
|
118
|
+
// normal outcome for, say, a worker on an offline node.
|
|
119
|
+
if (entries.length === 0) return null
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<MenuPrimitive.Root>
|
|
123
|
+
{trigger ? (
|
|
124
|
+
<MenuPrimitive.Trigger render={trigger} />
|
|
125
|
+
) : (
|
|
126
|
+
<MenuPrimitive.Trigger aria-label={label} className={cn(triggerButton, triggerClassName)}>
|
|
127
|
+
<MoreVerticalIcon className="h-4 w-4" />
|
|
128
|
+
</MenuPrimitive.Trigger>
|
|
129
|
+
)}
|
|
130
|
+
|
|
131
|
+
<MenuPrimitive.Portal container={container ?? undefined}>
|
|
132
|
+
<MenuPrimitive.Positioner className="isolate z-50 outline-none" align={align} side={side} sideOffset={sideOffset}>
|
|
133
|
+
<MenuPrimitive.Popup
|
|
134
|
+
data-slot="action-menu"
|
|
135
|
+
className={cn(
|
|
136
|
+
// `max-h-(--available-height)` + scroll: a menu of twenty actions
|
|
137
|
+
// opened from a card at the bottom of the page must scroll inside
|
|
138
|
+
// the viewport, never spill past its edge.
|
|
139
|
+
"max-h-(--available-height) min-w-52 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg shadow-black/[0.06] outline-none",
|
|
140
|
+
"transition-[transform,opacity] duration-150 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0",
|
|
141
|
+
className
|
|
142
|
+
)}
|
|
143
|
+
>
|
|
144
|
+
{entries.map((entry, i) =>
|
|
145
|
+
entry === "separator" ? (
|
|
146
|
+
<div key={`sep-${i}`} role="separator" className="-mx-1 my-1 h-px bg-border" />
|
|
147
|
+
) : (
|
|
148
|
+
<Item key={entry.key ?? (typeof entry.label === "string" ? entry.label : `item-${i}`)} item={entry} render={render} />
|
|
149
|
+
)
|
|
150
|
+
)}
|
|
151
|
+
</MenuPrimitive.Popup>
|
|
152
|
+
</MenuPrimitive.Positioner>
|
|
153
|
+
</MenuPrimitive.Portal>
|
|
154
|
+
</MenuPrimitive.Root>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const triggerButton =
|
|
159
|
+
"flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground/50 transition-colors outline-none hover:bg-accent hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 data-popup-open:bg-accent data-popup-open:text-foreground"
|
|
160
|
+
|
|
161
|
+
const itemClass =
|
|
162
|
+
"flex cursor-default items-center gap-2 rounded-md px-1.5 py-1 text-sm no-underline outline-none select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-3.5 [&_svg]:shrink-0"
|
|
163
|
+
|
|
164
|
+
const destructiveClass =
|
|
165
|
+
"text-destructive data-highlighted:bg-destructive/10 data-highlighted:text-destructive dark:data-highlighted:bg-destructive/20"
|
|
166
|
+
|
|
167
|
+
function Item({ item, render }: { item: ActionMenuItem; render?: { link?: ActionMenuLinkRender } }) {
|
|
168
|
+
const { label, icon: Icon, href, target, external, disabled, loading, destructive, shortcut, closeOnClick, onClick } = item
|
|
169
|
+
const className = cn(itemClass, destructive && destructiveClass)
|
|
170
|
+
const showExternal = external ?? target === "_blank"
|
|
171
|
+
|
|
172
|
+
const content = (
|
|
173
|
+
<>
|
|
174
|
+
{loading ? <Spinner className="size-3.5" /> : Icon ? <Icon /> : null}
|
|
175
|
+
<span className="min-w-0 flex-1 truncate">{label}</span>
|
|
176
|
+
{shortcut != null && <span className="text-xs text-muted-foreground">{shortcut}</span>}
|
|
177
|
+
{showExternal && <ExternalLinkIcon className="opacity-50" />}
|
|
178
|
+
</>
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
// A navigation is an `<a>` — Base UI's `LinkItem` renders one, so ⌘-click and
|
|
182
|
+
// "open in new tab" work like anywhere else. `render.link` swaps in the app's
|
|
183
|
+
// router link; without it, a plain anchor (the package never imports `next/link`).
|
|
184
|
+
if (href) {
|
|
185
|
+
return (
|
|
186
|
+
<MenuPrimitive.LinkItem
|
|
187
|
+
className={className}
|
|
188
|
+
closeOnClick
|
|
189
|
+
render={render?.link?.({ href, target }) ?? <a href={href} target={target} rel={target === "_blank" ? "noopener noreferrer" : undefined} />}
|
|
190
|
+
>
|
|
191
|
+
{content}
|
|
192
|
+
</MenuPrimitive.LinkItem>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<MenuPrimitive.Item className={className} disabled={disabled || loading} closeOnClick={closeOnClick ?? true} onClick={onClick}>
|
|
198
|
+
{content}
|
|
199
|
+
</MenuPrimitive.Item>
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Drops falsy entries, then every separator that would end up alone: leading,
|
|
205
|
+
* trailing, or next to another one.
|
|
206
|
+
*
|
|
207
|
+
* This is the whole reason separators are data here. Written by hand, a rule
|
|
208
|
+
* between two conditional groups needs its own condition — the union of the
|
|
209
|
+
* conditions below it — and that condition is what goes stale when an entry is
|
|
210
|
+
* added. Here the rule simply disappears when nothing follows it.
|
|
211
|
+
*/
|
|
212
|
+
export function normalizeEntries(entries: ActionMenuEntry[]): (ActionMenuItem | "separator")[] {
|
|
213
|
+
const out: (ActionMenuItem | "separator")[] = []
|
|
214
|
+
for (const entry of entries) {
|
|
215
|
+
if (!entry) continue
|
|
216
|
+
if (entry === "separator") {
|
|
217
|
+
if (out.length === 0 || out[out.length - 1] === "separator") continue
|
|
218
|
+
out.push("separator")
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
out.push(entry)
|
|
222
|
+
}
|
|
223
|
+
while (out.length > 0 && out[out.length - 1] === "separator") out.pop()
|
|
224
|
+
return out
|
|
225
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react"
|
|
4
|
+
import {
|
|
5
|
+
BoxIcon,
|
|
6
|
+
CheckIcon,
|
|
7
|
+
CircleIcon,
|
|
8
|
+
LayersIcon,
|
|
9
|
+
Loader2Icon,
|
|
10
|
+
MinusIcon,
|
|
11
|
+
PuzzleIcon,
|
|
12
|
+
TerminalIcon,
|
|
13
|
+
XIcon,
|
|
14
|
+
} from "lucide-react"
|
|
15
|
+
|
|
16
|
+
import { cn, formatDuration } from "../../utils"
|
|
17
|
+
|
|
18
|
+
export type BuildStepState = "pending" | "running" | "done" | "error" | "skipped"
|
|
19
|
+
|
|
20
|
+
/** The family a block belongs to — an icon, not a status. */
|
|
21
|
+
export type BuildStepKind = "image" | "runtime" | "feature" | "extensions" | "finalize"
|
|
22
|
+
|
|
23
|
+
export interface BuildStepItem {
|
|
24
|
+
id: string
|
|
25
|
+
label: string
|
|
26
|
+
kind: BuildStepKind
|
|
27
|
+
/** Second line: the base image ref, a feature's OCI ref, "3 extensions"… */
|
|
28
|
+
detail?: string
|
|
29
|
+
state: BuildStepState
|
|
30
|
+
/** ISO timestamps — the elapsed time of the running block ticks from `startedAt`. */
|
|
31
|
+
startedAt?: string
|
|
32
|
+
completedAt?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface BuildStepsProps {
|
|
36
|
+
steps: BuildStepItem[]
|
|
37
|
+
title?: string
|
|
38
|
+
/** Shown instead of the list when there is nothing to draw yet. */
|
|
39
|
+
emptyLabel?: string
|
|
40
|
+
className?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const KIND_ICON: Record<BuildStepKind, typeof BoxIcon> = {
|
|
44
|
+
image: LayersIcon,
|
|
45
|
+
runtime: TerminalIcon,
|
|
46
|
+
feature: PuzzleIcon,
|
|
47
|
+
extensions: PuzzleIcon,
|
|
48
|
+
finalize: BoxIcon,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `formatDuration` with a minutes tier on top.
|
|
53
|
+
*
|
|
54
|
+
* The package's formatter stops at seconds, which is right where it is used
|
|
55
|
+
* (a command, a request) and wrong here: pulling a base image or compiling a
|
|
56
|
+
* feature routinely passes the minute, and "127s" is a number one has to
|
|
57
|
+
* convert before reading it. Below a minute it delegates, so the two never
|
|
58
|
+
* drift on the part they share.
|
|
59
|
+
*/
|
|
60
|
+
function formatStepDuration(ms: number): string {
|
|
61
|
+
if (ms < 60_000) return formatDuration(ms)
|
|
62
|
+
const s = Math.round(ms / 1000)
|
|
63
|
+
const m = Math.floor(s / 60)
|
|
64
|
+
const rest = s % 60
|
|
65
|
+
return rest ? `${m}m ${rest}s` : `${m}m`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Elapsed for a block: fixed once it ended, ticking while it runs. */
|
|
69
|
+
function useStepDuration(step: BuildStepItem): string | null {
|
|
70
|
+
const running = step.state === "running" && !!step.startedAt
|
|
71
|
+
const [now, setNow] = useState(() => Date.now())
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!running) return
|
|
75
|
+
const id = setInterval(() => setNow(Date.now()), 1000)
|
|
76
|
+
return () => clearInterval(id)
|
|
77
|
+
}, [running])
|
|
78
|
+
|
|
79
|
+
if (!step.startedAt) return null
|
|
80
|
+
const start = new Date(step.startedAt).getTime()
|
|
81
|
+
if (Number.isNaN(start)) return null
|
|
82
|
+
const end = step.completedAt ? new Date(step.completedAt).getTime() : running ? now : null
|
|
83
|
+
if (end == null || Number.isNaN(end)) return null
|
|
84
|
+
return formatStepDuration(end - start)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function StepBullet({ state }: { state: BuildStepState }) {
|
|
88
|
+
const base = "flex h-5 w-5 shrink-0 items-center justify-center rounded-full border"
|
|
89
|
+
if (state === "running")
|
|
90
|
+
return (
|
|
91
|
+
<span className={cn(base, "border-primary/40 bg-primary/10 text-primary")}>
|
|
92
|
+
<Loader2Icon className="h-3 w-3 animate-spin motion-reduce:animate-none" />
|
|
93
|
+
</span>
|
|
94
|
+
)
|
|
95
|
+
if (state === "done")
|
|
96
|
+
return (
|
|
97
|
+
<span className={cn(base, "border-green-500/40 bg-green-500/10 text-green-600 dark:text-green-400")}>
|
|
98
|
+
<CheckIcon className="h-3 w-3" />
|
|
99
|
+
</span>
|
|
100
|
+
)
|
|
101
|
+
if (state === "error")
|
|
102
|
+
return (
|
|
103
|
+
<span className={cn(base, "border-destructive/40 bg-destructive/10 text-destructive")}>
|
|
104
|
+
<XIcon className="h-3 w-3" />
|
|
105
|
+
</span>
|
|
106
|
+
)
|
|
107
|
+
if (state === "skipped")
|
|
108
|
+
return (
|
|
109
|
+
<span className={cn(base, "border-border bg-muted/40 text-muted-foreground/60")}>
|
|
110
|
+
<MinusIcon className="h-3 w-3" />
|
|
111
|
+
</span>
|
|
112
|
+
)
|
|
113
|
+
return (
|
|
114
|
+
<span className={cn(base, "border-border bg-background text-muted-foreground/40")}>
|
|
115
|
+
<CircleIcon className="h-1.5 w-1.5 fill-current" />
|
|
116
|
+
</span>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function StepRow({ step, last }: { step: BuildStepItem; last: boolean }) {
|
|
121
|
+
const duration = useStepDuration(step)
|
|
122
|
+
const KindIcon = KIND_ICON[step.kind] ?? BoxIcon
|
|
123
|
+
const dim = step.state === "pending" || step.state === "skipped"
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<li className="relative flex gap-2.5 pb-2.5 last:pb-0">
|
|
127
|
+
{/* The rail joining the bullets — drawn behind them, stopped on the last row. */}
|
|
128
|
+
{!last && <span aria-hidden className="absolute left-[9px] top-5 h-full w-px bg-border" />}
|
|
129
|
+
<StepBullet state={step.state} />
|
|
130
|
+
|
|
131
|
+
<div className="min-w-0 flex-1">
|
|
132
|
+
<div className="flex items-baseline gap-2">
|
|
133
|
+
<span
|
|
134
|
+
className={cn(
|
|
135
|
+
"min-w-0 flex-1 truncate text-[12px] leading-5",
|
|
136
|
+
step.state === "running" && "font-medium text-foreground",
|
|
137
|
+
step.state === "done" && "text-foreground",
|
|
138
|
+
step.state === "error" && "font-medium text-destructive",
|
|
139
|
+
dim && "text-muted-foreground/70",
|
|
140
|
+
)}
|
|
141
|
+
title={step.label}
|
|
142
|
+
>
|
|
143
|
+
{step.label}
|
|
144
|
+
</span>
|
|
145
|
+
{duration && (
|
|
146
|
+
<span className="shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground">{duration}</span>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
{step.detail && (
|
|
150
|
+
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
|
|
151
|
+
<KindIcon className="h-2.5 w-2.5 shrink-0" />
|
|
152
|
+
<span className="min-w-0 truncate" title={step.detail}>
|
|
153
|
+
{step.detail}
|
|
154
|
+
</span>
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
</li>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* An image build, as the list of blocks it is actually made of, instead of a wall of log.
|
|
164
|
+
*
|
|
165
|
+
* A project image build pulls a base image, creates the user, installs the editor and the terminal
|
|
166
|
+
* tooling, every devcontainer feature and every VS Code extension, then bakes the DinD seed — all of
|
|
167
|
+
* it, until now, indistinguishable from the outside: one terminal scrolling. Here each of those is a
|
|
168
|
+
* row that lights up in turn, with its own duration, so "what is it doing, and for how long" is
|
|
169
|
+
* readable at a glance and a slow feature is visibly *the* slow one.
|
|
170
|
+
*
|
|
171
|
+
* Purely presentational — it draws the snapshot it's given (the app streams it from the build's log
|
|
172
|
+
* socket, and owns every byte of that transport). The whole plan is known upfront, so the blocks
|
|
173
|
+
* that haven't run yet are drawn greyed out rather than appearing one by one: it doubles as a table
|
|
174
|
+
* of contents for what the image is made of, which is what these blocks are on their way to becoming
|
|
175
|
+
* (a checklist of what to include).
|
|
176
|
+
*
|
|
177
|
+
* Sized by its container, scrolls inside it: give it a height (`className="h-full"` in a panel) and
|
|
178
|
+
* only the list scrolls — the header with its `done/total` counter stays put.
|
|
179
|
+
*/
|
|
180
|
+
export function BuildSteps({ steps, title = "Build steps", emptyLabel = "No step reported", className }: BuildStepsProps) {
|
|
181
|
+
const done = steps.filter((s) => s.state === "done").length
|
|
182
|
+
const failed = steps.some((s) => s.state === "error")
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<div data-slot="build-steps" className={cn("flex min-h-0 flex-col", className)}>
|
|
186
|
+
<div className="flex shrink-0 items-baseline justify-between gap-2 px-3 py-2">
|
|
187
|
+
<span className="text-[11px] font-medium text-muted-foreground">{title}</span>
|
|
188
|
+
{steps.length > 0 && (
|
|
189
|
+
<span
|
|
190
|
+
className={cn(
|
|
191
|
+
"font-mono text-[10px] tabular-nums",
|
|
192
|
+
failed ? "text-destructive" : "text-muted-foreground",
|
|
193
|
+
)}
|
|
194
|
+
>
|
|
195
|
+
{done}/{steps.length}
|
|
196
|
+
</span>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3">
|
|
201
|
+
{steps.length === 0 ? (
|
|
202
|
+
<p className="text-[11px] text-muted-foreground/70">{emptyLabel}</p>
|
|
203
|
+
) : (
|
|
204
|
+
<ol className="relative">
|
|
205
|
+
{steps.map((step, i) => (
|
|
206
|
+
<StepRow key={step.id} step={step} last={i === steps.length - 1} />
|
|
207
|
+
))}
|
|
208
|
+
</ol>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
@@ -92,6 +92,13 @@ export type { ChipListProps, ChipItem, ChipTone } from "./chip-list"
|
|
|
92
92
|
export { BuildCacheList, BuildStateLabel, PrebuildButton } from "./build-cache-list"
|
|
93
93
|
export type { BuildCacheListProps, PrebuildButtonProps } from "./build-cache-list"
|
|
94
94
|
|
|
95
|
+
// The build itself, while it runs: the blocks an image is made of, each lighting
|
|
96
|
+
// up in turn. Same domain as the cache list it sits next to (a project's image),
|
|
97
|
+
// hence this entry rather than one of its own — and domain it is, since the
|
|
98
|
+
// blocks are Spunto's (a devcontainer feature, a VS Code extension).
|
|
99
|
+
export { BuildSteps } from "./build-steps"
|
|
100
|
+
export type { BuildStepsProps, BuildStepItem, BuildStepKind, BuildStepState } from "./build-steps"
|
|
101
|
+
|
|
95
102
|
export { LifecycleCommands, LifecycleCommand } from "./lifecycle-commands"
|
|
96
103
|
export type { LifecycleCommandsProps, LifecycleCommandProps } from "./lifecycle-commands"
|
|
97
104
|
|
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
export { WorkerCard } from "./worker-card"
|
|
10
10
|
export type { WorkerCardProps, LinkRender } from "./worker-card"
|
|
11
11
|
|
|
12
|
+
// The card's `menu` prop is a list of these. Re-exported here (they also ship
|
|
13
|
+
// from the root entry) so an app can type its worker menu without importing the
|
|
14
|
+
// primitives entry just for that.
|
|
15
|
+
export { ActionMenu } from "../action-menu"
|
|
16
|
+
export type { ActionMenuProps, ActionMenuItem, ActionMenuEntry, ActionMenuLinkRender } from "../action-menu"
|
|
17
|
+
|
|
12
18
|
export { WorkerCockpit } from "./worker-cockpit"
|
|
13
19
|
export type { WorkerCockpitProps } from "./worker-cockpit"
|
|
14
20
|
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "lucide-react"
|
|
13
13
|
|
|
14
14
|
import { cn, formatDuration, formatRelativeTime } from "../../utils"
|
|
15
|
+
import { ActionMenu, type ActionMenuEntry, type ActionMenuLinkRender } from "../action-menu"
|
|
15
16
|
import { buttonVariants } from "../button-variants"
|
|
16
17
|
import { CreatorAvatar } from "./creator-avatar"
|
|
17
18
|
import { GitBranchChips, type GitRepoStatus } from "./git-branch-chips"
|
|
@@ -33,8 +34,12 @@ export interface WorkerCardProps {
|
|
|
33
34
|
* Escape hatches for app-specific rendering. `link` receives `{ href, className,
|
|
34
35
|
* children }` and must return an anchor-ish node (`next/link`, a router link…);
|
|
35
36
|
* without it the card falls back to a plain `<a>`.
|
|
37
|
+
*
|
|
38
|
+
* `menuLink` does the same for a `menu` entry carrying an `href`, but with the
|
|
39
|
+
* other contract: it returns an *element* that Base UI merges its own props
|
|
40
|
+
* and children into, so it takes no `children` of its own.
|
|
36
41
|
*/
|
|
37
|
-
render?: { link?: LinkRender }
|
|
42
|
+
render?: { link?: LinkRender; menuLink?: ActionMenuLinkRender }
|
|
38
43
|
|
|
39
44
|
creator?: WorkerCreator
|
|
40
45
|
nodeName?: string
|
|
@@ -71,7 +76,33 @@ export interface WorkerCardProps {
|
|
|
71
76
|
error?: string | null
|
|
72
77
|
onDismissError?: () => void
|
|
73
78
|
|
|
74
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* The `⋯` menu, top-right, **as data**: one entry is a `label`, optionally an
|
|
81
|
+
* `icon`, and an `onClick` (or an `href`) — the card builds the trigger, the
|
|
82
|
+
* popup and the items. Entries may be `false`/`null` (a menu is always
|
|
83
|
+
* conditional) and `"separator"` is an entry like any other; rules left alone
|
|
84
|
+
* by a missing neighbour are dropped. Empty or all-falsy → no `⋯` at all.
|
|
85
|
+
*
|
|
86
|
+
* What an entry *does* still belongs to the app: `onClick` is where the API
|
|
87
|
+
* call lives. What it *looks like* is ours — that's the difference with the
|
|
88
|
+
* `actions` slot below, which each app used to fill with its own twenty lines
|
|
89
|
+
* of menu markup.
|
|
90
|
+
*
|
|
91
|
+
* ```tsx
|
|
92
|
+
* menu={[
|
|
93
|
+
* codeUrl && { label: "Open in VS Code", icon: Code2Icon, href: codeUrl, target: "_blank" },
|
|
94
|
+
* "separator",
|
|
95
|
+
* isRunning && { label: "Stop", icon: SquareIcon, onClick: () => stop.mutate(), loading: stop.isPending },
|
|
96
|
+
* "separator",
|
|
97
|
+
* { label: "Delete", icon: Trash2Icon, destructive: true, onClick: () => del.mutate() },
|
|
98
|
+
* ]}
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
menu?: ActionMenuEntry[]
|
|
102
|
+
/**
|
|
103
|
+
* Escape hatch for a menu the data form can't express (a submenu, a checkbox
|
|
104
|
+
* item, a dialog opened from an entry). Takes precedence over `menu`.
|
|
105
|
+
*/
|
|
75
106
|
actions?: ReactNode
|
|
76
107
|
/** Replaces the default "View" footer entirely (extra buttons, a VS Code link…). */
|
|
77
108
|
footer?: ReactNode
|
|
@@ -86,10 +117,10 @@ export interface WorkerCardProps {
|
|
|
86
117
|
* on, and what you can do with it.
|
|
87
118
|
*
|
|
88
119
|
* **Purely presentational.** It receives a snapshot and draws it — no query, no
|
|
89
|
-
* mutation, no router. Everything that would talk to an API is a
|
|
90
|
-
* (`actions`, `footer`) or a callback (`onAddTag`, `onRebuild`…). That rule
|
|
91
|
-
* what makes the same card renderable by the dashboard, by Spunto Lite, and
|
|
92
|
-
* the showcase with hand-written fixtures. The card is the assembly; each brick
|
|
120
|
+
* mutation, no router. Everything that would talk to an API is data (`menu`), a
|
|
121
|
+
* slot (`actions`, `footer`) or a callback (`onAddTag`, `onRebuild`…). That rule
|
|
122
|
+
* is what makes the same card renderable by the dashboard, by Spunto Lite, and
|
|
123
|
+
* by the showcase with hand-written fixtures. The card is the assembly; each brick
|
|
93
124
|
* (`WorkerStatusDot`, `SetupProgress`, `ResourceBars`, `GitBranchChips`,
|
|
94
125
|
* `TagChips`) is exported on its own because the table view needs them without
|
|
95
126
|
* the card.
|
|
@@ -116,6 +147,7 @@ export function WorkerCard({
|
|
|
116
147
|
rebuilding = false,
|
|
117
148
|
error,
|
|
118
149
|
onDismissError,
|
|
150
|
+
menu,
|
|
119
151
|
actions,
|
|
120
152
|
footer,
|
|
121
153
|
viewLabel = "View",
|
|
@@ -215,7 +247,7 @@ export function WorkerCard({
|
|
|
215
247
|
</div>
|
|
216
248
|
</div>
|
|
217
249
|
|
|
218
|
-
{actions}
|
|
250
|
+
{actions ?? (menu && <ActionMenu items={menu} render={{ link: render?.menuLink }} />)}
|
|
219
251
|
</div>
|
|
220
252
|
|
|
221
253
|
{status.settingUp && (
|
package/src/index.ts
CHANGED
|
@@ -208,6 +208,13 @@ export type {
|
|
|
208
208
|
OAuthProvider,
|
|
209
209
|
} from "./components/auth"
|
|
210
210
|
|
|
211
|
+
// ActionMenu — the `⋯` menu described as data (`{ label, icon, onClick }[]`)
|
|
212
|
+
// rather than assembled from parts: the caller owns what an entry does, the
|
|
213
|
+
// package owns the markup, the states, and the separators (which are normalized,
|
|
214
|
+
// so a conditional entry can't leave a stray rule behind).
|
|
215
|
+
export { ActionMenu } from "./components/action-menu"
|
|
216
|
+
export type { ActionMenuProps, ActionMenuItem, ActionMenuEntry, ActionMenuLinkRender } from "./components/action-menu"
|
|
217
|
+
|
|
211
218
|
// Kbd — one keyboard cap, or a whole shortcut (`keys="mod+k"` → ⌘ K). Used by the
|
|
212
219
|
// palette, exported on its own for anywhere else a shortcut needs drawing.
|
|
213
220
|
export { Kbd } from "./components/kbd"
|