@spunto/design-system 0.11.1 → 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.
@@ -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`