@spunto/design-system 0.11.1 → 0.13.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
+ }
@@ -35,6 +35,14 @@ export function repoName(repo: GitRepoStatus): string {
35
35
  return repo.name ?? repo.path.split("/").filter(Boolean).pop() ?? repo.path
36
36
  }
37
37
 
38
+ /**
39
+ * `max-w-full` is what keeps a chip inside the card: without it a long
40
+ * "repo · branch" is a single unbreakable row that runs under the card's
41
+ * rounded edge and gets clipped.
42
+ */
43
+ const CHIP =
44
+ "inline-flex min-w-0 max-w-full items-center gap-1 rounded-md border border-border bg-muted/40 px-2 py-0.5 font-mono text-[11px] text-muted-foreground"
45
+
38
46
  /** One chip per checked-out repository, wrapping onto as many lines as needed. */
39
47
  export function GitBranchChips({ repos, repoHref, className }: GitBranchChipsProps) {
40
48
  if (!repos.length) return null
@@ -52,13 +60,7 @@ export function GitBranchChips({ repos, repoHref, className }: GitBranchChipsPro
52
60
  key={repo.path}
53
61
  {...(href ? { href, target: "_blank", rel: "noopener noreferrer" } : {})}
54
62
  title={repo.path}
55
- // `max-w-full` is what keeps a chip inside the card: without it a
56
- // long "repo · branch" is a single unbreakable row that runs under
57
- // the card's rounded edge and gets clipped.
58
- className={cn(
59
- "inline-flex min-w-0 max-w-full items-center gap-1 rounded-md border border-border bg-muted/40 px-2 py-0.5 font-mono text-[11px] text-muted-foreground",
60
- href && "transition-colors hover:border-border/80 hover:bg-muted/70"
61
- )}
63
+ className={cn(CHIP, href && "transition-colors hover:border-border/80 hover:bg-muted/70")}
62
64
  >
63
65
  <span className="shrink-0 font-medium text-foreground/70">{repoName(repo)}</span>
64
66
  <span className="mx-0.5 shrink-0 text-muted-foreground/40">·</span>
@@ -75,6 +77,27 @@ export function GitBranchChips({ repos, repoHref, className }: GitBranchChipsPro
75
77
  )
76
78
  }
77
79
 
80
+ export interface RequestedBranchChipProps {
81
+ branch: string
82
+ className?: string
83
+ }
84
+
85
+ /**
86
+ * The branch a worker was *asked* to check out, as a lone chip — no repo name,
87
+ * because at that point nothing is cloned yet and there is none to show. Same
88
+ * shell as the chips above on purpose: it's the placeholder they replace once
89
+ * the live status arrives, and a different-looking chip would read as a
90
+ * different kind of information.
91
+ */
92
+ export function RequestedBranchChip({ branch, className }: RequestedBranchChipProps) {
93
+ return (
94
+ <span data-slot="requested-branch-chip" className={cn(CHIP, className)} title={`Requested branch: ${branch}`}>
95
+ <GitBranchIcon className="h-2.5 w-2.5 shrink-0" />
96
+ <span className="min-w-0 truncate">{branch}</span>
97
+ </span>
98
+ )
99
+ }
100
+
78
101
  /** Amber for local work not yet committed/pushed, muted for what's merely behind. */
79
102
  function RepoCounters({ repo }: { repo: GitRepoStatus }) {
80
103
  return (
@@ -18,8 +18,8 @@ export type { SetupProgressProps, StepState } from "./setup-progress"
18
18
  export { ResourceBars } from "./resource-bars"
19
19
  export type { ResourceBarsProps, WorkerStats } from "./resource-bars"
20
20
 
21
- export { GitBranchChips, GitStatusSummary, repoName } from "./git-branch-chips"
22
- export type { GitBranchChipsProps, GitRepoStatus, GitStatusSummaryProps } from "./git-branch-chips"
21
+ export { GitBranchChips, GitStatusSummary, RequestedBranchChip, repoName } from "./git-branch-chips"
22
+ export type { GitBranchChipsProps, GitRepoStatus, GitStatusSummaryProps, RequestedBranchChipProps } from "./git-branch-chips"
23
23
 
24
24
  export { TagChips } from "./tag-chips"
25
25
  export type { TagChipsProps } from "./tag-chips"
@@ -31,6 +31,12 @@ export interface WorkerCardWorker {
31
31
  /** Docker-level state: `running` | `stopped` | `exited` | `created` | `not_found` | `error`. */
32
32
  dockerState?: string | null
33
33
  createdAt?: string | null
34
+ /**
35
+ * Branch asked for at creation — what the worker is *going to* check out. The
36
+ * live per-repo status (`gitStatus`) says what it *did* check out, and takes
37
+ * over as soon as it exists.
38
+ */
39
+ branch?: string | null
34
40
  projectVersion?: number | null
35
41
  tags?: string[] | null
36
42
  setupStatus?: WorkerSetupStatus | null
@@ -14,7 +14,7 @@ import {
14
14
  import { cn, formatDuration, formatRelativeTime } from "../../utils"
15
15
  import { buttonVariants } from "../button-variants"
16
16
  import { CreatorAvatar } from "./creator-avatar"
17
- import { GitBranchChips, type GitRepoStatus } from "./git-branch-chips"
17
+ import { GitBranchChips, RequestedBranchChip, type GitRepoStatus } from "./git-branch-chips"
18
18
  import { ResourceBars, type WorkerStats } from "./resource-bars"
19
19
  import { SetupProgress, setupTotalMs } from "./setup-progress"
20
20
  import { TagChips } from "./tag-chips"
@@ -209,10 +209,16 @@ export function WorkerCard({
209
209
  </div>
210
210
  )}
211
211
 
212
- {repos.length > 0 && (
212
+ {repos.length > 0 ? (
213
213
  <div className="px-4 pb-3">
214
214
  <GitBranchChips repos={repos} repoHref={repoHref} />
215
215
  </div>
216
+ ) : (
217
+ worker.branch && (
218
+ <div className="px-4 pb-3">
219
+ <RequestedBranchChip branch={worker.branch} />
220
+ </div>
221
+ )
216
222
  )}
217
223
 
218
224
  {settled && stats && (
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`