@spunto/design-system 0.11.0 → 0.12.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 +45 -1
- package/package.json +1 -1
- package/src/components/projects/advanced-sections.tsx +16 -4
- package/src/components/projects/build-cache-list.tsx +104 -0
- package/src/components/projects/chip-list.tsx +168 -0
- package/src/components/projects/deploy-key-section.tsx +76 -0
- package/src/components/projects/index.ts +72 -6
- package/src/components/projects/integrations-list.tsx +87 -0
- package/src/components/projects/lifecycle-commands.tsx +50 -0
- package/src/components/projects/panel-meta.tsx +40 -0
- package/src/components/projects/panel-section.tsx +68 -0
- package/src/components/projects/panel-types.ts +101 -0
- package/src/components/projects/project-form.tsx +12 -5
- package/src/components/projects/project-panel.tsx +330 -0
- package/src/components/projects/project-state.tsx +47 -0
- package/src/components/projects/repository-list.tsx +108 -0
- package/src/components/projects/version-history.tsx +116 -0
- package/src/utils.ts +22 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// The repositories a worker gets cloned into its `/workspace`.
|
|
2
|
+
|
|
3
|
+
import { GitBranchIcon } from "lucide-react"
|
|
4
|
+
|
|
5
|
+
import { cn } from "../../utils"
|
|
6
|
+
import { PublisherAvatar } from "../devcontainer/publisher-badge"
|
|
7
|
+
import { githubAvatarUrl } from "../devcontainer/registry"
|
|
8
|
+
import type { ProjectPanelRepository } from "./panel-types"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Short, human name of a project repository.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately *not* the `repoName` of `@spunto/design-system/workers`: that one
|
|
14
|
+
* names a **checkout** (it shortens a filesystem path), this one names a
|
|
15
|
+
* **remote** (a slug or a clone URL). The dashboard used to do
|
|
16
|
+
* `repo.project.split("/").pop()`, which mangles a SSH remote
|
|
17
|
+
* (`git@host:org/repo.git` has no `/` before `org`) and keeps the `.git`
|
|
18
|
+
* suffix; Lite's version handled both, so Lite's version is the one that
|
|
19
|
+
* survived here.
|
|
20
|
+
*/
|
|
21
|
+
export function projectRepoName(repo: ProjectPanelRepository): string {
|
|
22
|
+
const source = repo.cloneUrl || repo.project || repo.workspacePath
|
|
23
|
+
return source.split(/[/:]/).filter(Boolean).pop()?.replace(/\.git$/, "") ?? source
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Full remote as displayed under the name — URL when there is one, slug otherwise. */
|
|
27
|
+
export function projectRepoSource(repo: ProjectPanelRepository): string {
|
|
28
|
+
return repo.cloneUrl || repo.project || ""
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* GitHub owner behind a repository, when there is one.
|
|
33
|
+
*
|
|
34
|
+
* Same idea as `derivePublisher` for OCI refs: nothing in the payload names an
|
|
35
|
+
* owner, but the remote already does — `owner/repo`, `git@github.com:owner/repo.git`
|
|
36
|
+
* and `https://github.com/owner/repo` all say it, and `github.com/<owner>.png`
|
|
37
|
+
* turns it into an avatar without an API call. Anything else (a Gitea host, a
|
|
38
|
+
* bare slug with no owner) returns `undefined`, and the row keeps its git glyph
|
|
39
|
+
* rather than showing a stranger's face.
|
|
40
|
+
*/
|
|
41
|
+
export function projectRepoOwner(repo: ProjectPanelRepository): string | undefined {
|
|
42
|
+
const url = repo.cloneUrl?.trim()
|
|
43
|
+
if (url) {
|
|
44
|
+
const match = /^(?:git@|(?:ssh|git|https?):\/\/(?:[^@/]*@)?)github\.com[:/]+([^/]+)\/[^/]+$/i.exec(
|
|
45
|
+
url.replace(/\.git$/, "")
|
|
46
|
+
)
|
|
47
|
+
return match?.[1]
|
|
48
|
+
}
|
|
49
|
+
const slug = repo.project?.trim()
|
|
50
|
+
if (slug && !slug.includes(":") && slug.split("/").filter(Boolean).length === 2) return slug.split("/")[0]
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RepositoryListProps {
|
|
55
|
+
repositories: ProjectPanelRepository[]
|
|
56
|
+
className?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One block per repository: short name, full remote, destination folder. All
|
|
61
|
+
* three lines truncate — a clone URL is a single unbreakable token and the panel
|
|
62
|
+
* is 288 px wide, so the alternative is a card that grows past its column.
|
|
63
|
+
*/
|
|
64
|
+
export function RepositoryList({ repositories, className }: RepositoryListProps) {
|
|
65
|
+
if (!repositories.length) return null
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div data-slot="repository-list" className={cn("space-y-1.5", className)}>
|
|
69
|
+
{repositories.map((repo) => {
|
|
70
|
+
const source = projectRepoSource(repo)
|
|
71
|
+
const owner = projectRepoOwner(repo)
|
|
72
|
+
return (
|
|
73
|
+
<div key={repo.id} className="min-w-0 rounded-lg bg-muted/50 px-3 py-2">
|
|
74
|
+
<div className="mb-0.5 flex min-w-0 items-center gap-1.5">
|
|
75
|
+
{/* One fixed 14 px box whatever it holds — the two lines below are
|
|
76
|
+
indented by exactly its width + the gap (`pl-5`), so an avatar
|
|
77
|
+
and a git glyph leave the block aligned the same way. */}
|
|
78
|
+
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
|
79
|
+
{owner ? (
|
|
80
|
+
<PublisherAvatar
|
|
81
|
+
publisher={{ name: owner, login: owner, avatarUrl: githubAvatarUrl(owner) }}
|
|
82
|
+
className="h-3.5 w-3.5"
|
|
83
|
+
/>
|
|
84
|
+
) : (
|
|
85
|
+
<GitBranchIcon className="h-3 w-3 text-primary" />
|
|
86
|
+
)}
|
|
87
|
+
</span>
|
|
88
|
+
<span className="min-w-0 truncate font-mono text-xs font-medium text-foreground/90">
|
|
89
|
+
{projectRepoName(repo)}
|
|
90
|
+
</span>
|
|
91
|
+
</div>
|
|
92
|
+
{source && (
|
|
93
|
+
<p title={source} className="truncate pl-5 font-mono text-[11px] text-muted-foreground/70">
|
|
94
|
+
{source}
|
|
95
|
+
</p>
|
|
96
|
+
)}
|
|
97
|
+
<p
|
|
98
|
+
title={`/workspace/${repo.workspacePath}`}
|
|
99
|
+
className="truncate pl-5 font-mono text-[11px] text-muted-foreground/50"
|
|
100
|
+
>
|
|
101
|
+
→ /workspace/{repo.workspacePath}
|
|
102
|
+
</p>
|
|
103
|
+
</div>
|
|
104
|
+
)
|
|
105
|
+
})}
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import { ChevronDownIcon, HistoryIcon, RotateCcwIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn, formatShortDate } from "../../utils"
|
|
7
|
+
import { RegistryRef } from "../devcontainer/registry-ref"
|
|
8
|
+
import { Eyebrow } from "./panel-section"
|
|
9
|
+
import type { ProjectVersionEntry } from "./panel-types"
|
|
10
|
+
|
|
11
|
+
export interface VersionHistoryProps {
|
|
12
|
+
versions: ProjectVersionEntry[]
|
|
13
|
+
/** Which entry is the live config — gets the "current" badge, loses Restore. */
|
|
14
|
+
currentVersion?: number
|
|
15
|
+
/**
|
|
16
|
+
* Restoring is a mutation, so it stays the app's. The callback gets the whole
|
|
17
|
+
* entry because the two APIs address a version differently: the dashboard by
|
|
18
|
+
* `id`, Lite by `version`.
|
|
19
|
+
*/
|
|
20
|
+
onRestore?: (version: ProjectVersionEntry) => void
|
|
21
|
+
/** Id of the entry being restored → spinner on that row, others disarmed. */
|
|
22
|
+
restoringVersionId?: string | null
|
|
23
|
+
/** Inline error above the list (the dashboard shows it here; Lite toasts). */
|
|
24
|
+
error?: string | null
|
|
25
|
+
defaultOpen?: boolean
|
|
26
|
+
title?: string
|
|
27
|
+
className?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Collapsed-by-default history of the project's config versions.
|
|
32
|
+
*
|
|
33
|
+
* The open/closed state is local on purpose: it is UX, not data — nothing
|
|
34
|
+
* outside needs to know, and hoisting it would make every consumer carry a
|
|
35
|
+
* `useState` for it. `defaultOpen` covers the one case where it matters (a
|
|
36
|
+
* showcase, a deep link).
|
|
37
|
+
*/
|
|
38
|
+
export function VersionHistory({
|
|
39
|
+
versions,
|
|
40
|
+
currentVersion,
|
|
41
|
+
onRestore,
|
|
42
|
+
restoringVersionId,
|
|
43
|
+
error,
|
|
44
|
+
defaultOpen = false,
|
|
45
|
+
title = "Version history",
|
|
46
|
+
className,
|
|
47
|
+
}: VersionHistoryProps) {
|
|
48
|
+
const [open, setOpen] = useState(defaultOpen)
|
|
49
|
+
if (!versions.length) return null
|
|
50
|
+
|
|
51
|
+
const restoring = restoringVersionId != null
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div data-slot="version-history" className={className}>
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
onClick={() => setOpen((v) => !v)}
|
|
58
|
+
aria-expanded={open}
|
|
59
|
+
className="flex w-full cursor-pointer items-center justify-between gap-2 text-left"
|
|
60
|
+
>
|
|
61
|
+
<Eyebrow icon={<HistoryIcon className="h-3 w-3" />}>{title}</Eyebrow>
|
|
62
|
+
<ChevronDownIcon
|
|
63
|
+
className={cn("h-3 w-3 shrink-0 text-muted-foreground/50 transition-transform", open && "rotate-180")}
|
|
64
|
+
/>
|
|
65
|
+
</button>
|
|
66
|
+
|
|
67
|
+
{open && (
|
|
68
|
+
<div className="mt-2 space-y-1">
|
|
69
|
+
{error && <p className="mb-2 text-xs text-destructive">{error}</p>}
|
|
70
|
+
{versions.map((v) => {
|
|
71
|
+
const isCurrent = currentVersion != null && v.version === currentVersion
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
key={v.id}
|
|
75
|
+
className={cn(
|
|
76
|
+
"flex items-center justify-between gap-2 rounded-lg px-2.5 py-2",
|
|
77
|
+
isCurrent ? "border border-primary/20 bg-primary/5" : "bg-muted/40"
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
<div className="min-w-0">
|
|
81
|
+
<div className="flex items-center gap-1.5">
|
|
82
|
+
<span className="font-mono text-xs font-semibold">v{v.version}</span>
|
|
83
|
+
{isCurrent && (
|
|
84
|
+
<span className="inline-flex h-4 shrink-0 items-center rounded-full border border-primary/30 bg-primary/10 px-1.5 text-[9px] leading-none text-primary">
|
|
85
|
+
current
|
|
86
|
+
</span>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
<p className="mt-0.5 truncate text-[10px] text-muted-foreground/60">
|
|
90
|
+
{formatShortDate(v.createdAt, { time: true })}
|
|
91
|
+
</p>
|
|
92
|
+
{/* Same reference treatment as the Image section: registry
|
|
93
|
+
dimmed, `name:tag` kept whole — that's the half that tells
|
|
94
|
+
two versions apart. */}
|
|
95
|
+
{v.image && <RegistryRef value={v.image} className="max-w-full" />}
|
|
96
|
+
</div>
|
|
97
|
+
{!isCurrent && onRestore && (
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
title="Restore this version"
|
|
101
|
+
disabled={restoring}
|
|
102
|
+
onClick={() => onRestore(v)}
|
|
103
|
+
className="ml-2 flex shrink-0 cursor-pointer items-center gap-1 text-[10px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
|
|
104
|
+
>
|
|
105
|
+
<RotateCcwIcon className={cn("h-3 w-3", restoringVersionId === v.id && "animate-spin")} />
|
|
106
|
+
Restore
|
|
107
|
+
</button>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
)
|
|
111
|
+
})}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
package/src/utils.ts
CHANGED
|
@@ -23,6 +23,28 @@ export function formatRelativeTime(date: string | number | Date): string {
|
|
|
23
23
|
return `${Math.floor(hours / 24)}d ago`
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Short absolute date, French style: "12 mars 2026", or "12 mars 14:03" with
|
|
28
|
+
* `time`.
|
|
29
|
+
*
|
|
30
|
+
* The one date formatter of the package — both project panels used to call
|
|
31
|
+
* `toLocaleDateString("fr-FR", …)` inline, in four places, with two different
|
|
32
|
+
* option sets that had to be kept in sync by hand. The rendering is unchanged;
|
|
33
|
+
* `locale` exists for the day the product stops being French-only.
|
|
34
|
+
*/
|
|
35
|
+
export function formatShortDate(
|
|
36
|
+
date: string | number | Date,
|
|
37
|
+
{ time = false, locale = "fr-FR" }: { time?: boolean; locale?: string } = {}
|
|
38
|
+
): string {
|
|
39
|
+
const ms = date instanceof Date ? date.getTime() : new Date(date).getTime()
|
|
40
|
+
if (!Number.isFinite(ms)) return ""
|
|
41
|
+
return new Date(ms).toLocaleDateString(locale, {
|
|
42
|
+
day: "numeric",
|
|
43
|
+
month: "short",
|
|
44
|
+
...(time ? { hour: "2-digit", minute: "2-digit" } : { year: "numeric" }),
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
26
48
|
/** Short duration, one significant decimal under 10 s ("840ms", "4.2s", "38s"). */
|
|
27
49
|
export function formatDuration(ms: number): string {
|
|
28
50
|
if (ms >= 1000) return `${(ms / 1000).toFixed(ms >= 10_000 ? 0 : 1)}s`
|