@spunto/design-system 0.23.0 → 0.25.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 +17 -2
- package/package.json +9 -3
- package/src/components/projects/index.ts +1 -1
- package/src/components/projects/project-form.tsx +9 -13
- package/src/components/projects/repo-list.test.tsx +192 -0
- package/src/components/projects/repo-list.tsx +162 -95
- package/src/components/projects/types.ts +7 -4
- package/src/components/workers/types.ts +1 -1
- package/src/components/workers/worker-status.tsx +12 -1
package/README.md
CHANGED
|
@@ -312,9 +312,24 @@ hand-typed reference that degrades cleanly.
|
|
|
312
312
|
secrets") instead of showing a bare chevron, and the form opens it by itself
|
|
313
313
|
when any of those sections already has a value — so editing a project never
|
|
314
314
|
hides a setting you made earlier.
|
|
315
|
+
- **Git hosting is a list of providers, not GitHub** (0.21.0). `gitProviders`
|
|
316
|
+
(id, name, `connected`, `accounts`, `installUrl`, `operations`, `icon`) and
|
|
317
|
+
`gitRepos` (each tagged with its `provider`) replace `githubRepos` / `github` /
|
|
318
|
+
`connectUrl` / `githubIcon`, and `ProjectRepo.provider` widens from
|
|
319
|
+
`"github" | "git"` to a `string` — the set of forges belongs to the deployment,
|
|
320
|
+
not to this package. The component reads `operations` to decide what to offer
|
|
321
|
+
rather than branching on a provider id, so a second forge needs no change here.
|
|
322
|
+
`"git"` (exported as `RAW_GIT`) still means "a raw clone URL", which is the
|
|
323
|
+
*absence* of a provider rather than one more of them.
|
|
324
|
+
- **One "Add repository" button, with no brand on it.** The forge is a property
|
|
325
|
+
of the repository you pick, not a decision to make before you know which
|
|
326
|
+
repository you want — so the field offers every connected provider's repos at
|
|
327
|
+
once and `renderRepoField`'s `onChange(value, option?)` hands back the chosen
|
|
328
|
+
entry, which is how the row learns its provider. A brand glyph on a generic
|
|
329
|
+
button is wrong as soon as two providers are connected.
|
|
315
330
|
- **Nothing fetches.** Catalogs come in as data, the marketplace search is
|
|
316
|
-
`onSearchExtensions`, the
|
|
317
|
-
plain `<input list>` + `<datalist>`, no dependency, keyboard-friendly), and
|
|
331
|
+
`onSearchExtensions`, the repository combobox is `renderRepoField` (without it:
|
|
332
|
+
a plain `<input list>` + `<datalist>`, no dependency, keyboard-friendly), and
|
|
318
333
|
`extras` slips app-specific content *inside* a section — the deploy key card,
|
|
319
334
|
a warning — without forking the component.
|
|
320
335
|
- **Its bricks, exported individually**: `FormSection`, `Field`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spunto/design-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -61,7 +61,8 @@
|
|
|
61
61
|
"README.md"
|
|
62
62
|
],
|
|
63
63
|
"scripts": {
|
|
64
|
-
"typecheck": "tsc --noEmit"
|
|
64
|
+
"typecheck": "tsc --noEmit",
|
|
65
|
+
"test": "vitest run"
|
|
65
66
|
},
|
|
66
67
|
"dependencies": {
|
|
67
68
|
"@xterm/addon-fit": "^0.11.0",
|
|
@@ -81,8 +82,13 @@
|
|
|
81
82
|
},
|
|
82
83
|
"devDependencies": {
|
|
83
84
|
"@base-ui/react": "^1.6.0",
|
|
85
|
+
"@testing-library/react": "^16.3.0",
|
|
86
|
+
"@testing-library/user-event": "^14.6.1",
|
|
84
87
|
"@types/react": "^19",
|
|
85
88
|
"@types/react-dom": "^19",
|
|
86
|
-
"
|
|
89
|
+
"@vitejs/plugin-react": "^5.0.4",
|
|
90
|
+
"jsdom": "^28.0.0",
|
|
91
|
+
"typescript": "^5",
|
|
92
|
+
"vitest": "^4.1.11"
|
|
87
93
|
}
|
|
88
94
|
}
|
|
@@ -39,7 +39,7 @@ export { BuildManifest } from "./build-manifest"
|
|
|
39
39
|
export type { BuildManifestProps, ManifestRow } from "./build-manifest"
|
|
40
40
|
|
|
41
41
|
export { RepoList, deriveFromGitUrl } from "./repo-list"
|
|
42
|
-
export type { RepoListProps,
|
|
42
|
+
export type { RepoListProps, GitRepoOption, GitProvider, RepoFieldRender } from "./repo-list"
|
|
43
43
|
|
|
44
44
|
export { SecretList } from "./secret-list"
|
|
45
45
|
export type { SecretListProps } from "./secret-list"
|
|
@@ -23,7 +23,7 @@ import { ImagePicker, type ImagePickerProps } from "./image-picker"
|
|
|
23
23
|
import { AdvancedDisclosure, advancedSummary } from "./advanced-sections"
|
|
24
24
|
import { BuildManifest, type ManifestRow } from "./build-manifest"
|
|
25
25
|
import { Field, FormSection, type SectionAccent } from "./form-section"
|
|
26
|
-
import { RepoList, type
|
|
26
|
+
import { RepoList, type GitProvider, type GitRepoOption, type RepoFieldRender } from "./repo-list"
|
|
27
27
|
import { SecretList } from "./secret-list"
|
|
28
28
|
import {
|
|
29
29
|
CREATE_SECTIONS,
|
|
@@ -115,12 +115,12 @@ export interface ProjectFormProps<TValue extends ProjectFormValue = ProjectFormV
|
|
|
115
115
|
/** Same passthrough for the feature catalog. */
|
|
116
116
|
featurePicker?: Pick<FeaturePickerProps, "layout" | "detail">
|
|
117
117
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
/** Providers that can host repositories for this org (RFC 0023 — whatever implements git). */
|
|
119
|
+
gitProviders?: GitProvider[]
|
|
120
|
+
/** Repositories reachable across every connected provider, each tagged with its provider. */
|
|
121
|
+
gitRepos?: GitRepoOption[]
|
|
121
122
|
/** App-level combobox for the repo field; without it, a plain input + datalist. */
|
|
122
123
|
renderRepoField?: RepoFieldRender
|
|
123
|
-
githubIcon?: ReactNode
|
|
124
124
|
|
|
125
125
|
/** Searches Open VSX (or any marketplace). Absent → extensions are typed by hand. */
|
|
126
126
|
onSearchExtensions?: (query: string) => Promise<VscodeExtensionEntry[]>
|
|
@@ -194,11 +194,9 @@ export function ProjectForm<TValue extends ProjectFormValue = ProjectFormValue>(
|
|
|
194
194
|
features = [],
|
|
195
195
|
imagePicker,
|
|
196
196
|
featurePicker,
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
connectUrl,
|
|
197
|
+
gitProviders,
|
|
198
|
+
gitRepos,
|
|
200
199
|
renderRepoField,
|
|
201
|
-
githubIcon,
|
|
202
200
|
onSearchExtensions,
|
|
203
201
|
savedSecrets,
|
|
204
202
|
extras,
|
|
@@ -370,11 +368,9 @@ export function ProjectForm<TValue extends ProjectFormValue = ProjectFormValue>(
|
|
|
370
368
|
<RepoList
|
|
371
369
|
repos={value.repositories}
|
|
372
370
|
onChange={(repositories) => patch({ repositories })}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
connectUrl={connectUrl}
|
|
371
|
+
gitProviders={gitProviders}
|
|
372
|
+
gitRepos={gitRepos}
|
|
376
373
|
renderRepoField={renderRepoField}
|
|
377
|
-
githubIcon={githubIcon}
|
|
378
374
|
/>
|
|
379
375
|
)
|
|
380
376
|
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row identity in the repository list.
|
|
3
|
+
*
|
|
4
|
+
* The bug this exists for: adding two repositories and picking one made **both** rows change.
|
|
5
|
+
* Every field in this list is addressed by row id, so anything that lets two rows share one — or
|
|
6
|
+
* that re-keys a row mid-edit — silently edits the wrong repository. jsdom is enough to catch it,
|
|
7
|
+
* which matters because the browser path needs a Chrome this sandbox cannot always start.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from "vitest"
|
|
10
|
+
import { useState } from "react"
|
|
11
|
+
import { render, screen, fireEvent } from "@testing-library/react"
|
|
12
|
+
import { RepoList } from "./repo-list"
|
|
13
|
+
import type { ProjectRepo } from "./types"
|
|
14
|
+
|
|
15
|
+
const PROVIDERS = [
|
|
16
|
+
{ id: "github", name: "GitHub", connected: true, operations: ["listRepos", "cloneCredential"] },
|
|
17
|
+
{ id: "gitlab", name: "GitLab", connected: true, operations: ["listRepos", "cloneCredential"] },
|
|
18
|
+
]
|
|
19
|
+
const REPOS = [
|
|
20
|
+
{ provider: "github", fullName: "acme/alpha" },
|
|
21
|
+
{ provider: "github", fullName: "acme/beta" },
|
|
22
|
+
{ provider: "gitlab", fullName: "group/gamma" },
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The list as an app drives it: controlled, with the parent owning the array.
|
|
27
|
+
*
|
|
28
|
+
* `renderRepoField` mirrors what the dashboard supplies — a real combobox whose options span every
|
|
29
|
+
* connected provider — because the plain `<input list>` fallback and a combobox exercise different
|
|
30
|
+
* code paths, and it was the combobox one that broke.
|
|
31
|
+
*/
|
|
32
|
+
function Harness({
|
|
33
|
+
onRepos,
|
|
34
|
+
withCombobox = false,
|
|
35
|
+
initial = [],
|
|
36
|
+
}: {
|
|
37
|
+
onRepos?: (r: ProjectRepo[]) => void
|
|
38
|
+
withCombobox?: boolean
|
|
39
|
+
initial?: ProjectRepo[]
|
|
40
|
+
}) {
|
|
41
|
+
const [repos, setRepos] = useState<ProjectRepo[]>(initial)
|
|
42
|
+
return (
|
|
43
|
+
<RepoList
|
|
44
|
+
repos={repos}
|
|
45
|
+
onChange={(next) => {
|
|
46
|
+
setRepos(next)
|
|
47
|
+
onRepos?.(next)
|
|
48
|
+
}}
|
|
49
|
+
gitProviders={PROVIDERS}
|
|
50
|
+
gitRepos={REPOS}
|
|
51
|
+
renderRepoField={
|
|
52
|
+
withCombobox
|
|
53
|
+
? ({ value, onChange, options, placeholder, id }) => (
|
|
54
|
+
<div>
|
|
55
|
+
<input
|
|
56
|
+
placeholder={placeholder}
|
|
57
|
+
value={value}
|
|
58
|
+
aria-label={`repo-field-${id}`}
|
|
59
|
+
onChange={(e) => onChange(e.target.value)}
|
|
60
|
+
/>
|
|
61
|
+
{options.map((option) => (
|
|
62
|
+
<button
|
|
63
|
+
key={`${option.provider}:${option.fullName}`}
|
|
64
|
+
type="button"
|
|
65
|
+
aria-label={`pick-${id}-${option.fullName}`}
|
|
66
|
+
onClick={() => onChange(option.fullName, option)}
|
|
67
|
+
/>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
: undefined
|
|
72
|
+
}
|
|
73
|
+
/>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The repository field of each row, in document order. */
|
|
78
|
+
function repoInputs(): HTMLInputElement[] {
|
|
79
|
+
return Array.from(document.querySelectorAll("input")).filter((i) =>
|
|
80
|
+
/owner\/repo|Search or type/.test(i.placeholder),
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe("RepoList row identity", () => {
|
|
85
|
+
it("does not reuse an id already held by a stored row", () => {
|
|
86
|
+
// The edit page's case, and the bug users actually hit: ids are **persisted**, so a project
|
|
87
|
+
// saved with `repo-1` comes back as `repo-1` — while the counter restarts at 0 on each page
|
|
88
|
+
// load and handed out `repo-1` again. Two rows, one id, both edited at once.
|
|
89
|
+
const stored: ProjectRepo[] = [
|
|
90
|
+
{ id: "repo-1", provider: "github", project: "acme/alpha", workspacePath: "alpha", workspacePathTouched: true },
|
|
91
|
+
]
|
|
92
|
+
let latest: ProjectRepo[] = []
|
|
93
|
+
render(<Harness withCombobox initial={stored} onRepos={(r) => (latest = r)} />)
|
|
94
|
+
|
|
95
|
+
fireEvent.click(screen.getByRole("button", { name: /add repository/i }))
|
|
96
|
+
expect(new Set(latest.map((r) => r.id)).size).toBe(2)
|
|
97
|
+
|
|
98
|
+
// …and the stored row keeps its value when the new one is filled.
|
|
99
|
+
fireEvent.click(screen.getByLabelText(`pick-${latest[1].id}-acme/beta`))
|
|
100
|
+
expect(latest.map((r) => r.project)).toEqual(["acme/alpha", "acme/beta"])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("stays unique when stored rows are named out of order", () => {
|
|
104
|
+
const stored: ProjectRepo[] = [
|
|
105
|
+
{ id: "repo-2", provider: "github", project: "acme/alpha", workspacePath: "a" },
|
|
106
|
+
{ id: "repo-5", provider: "github", project: "acme/beta", workspacePath: "b" },
|
|
107
|
+
]
|
|
108
|
+
let latest: ProjectRepo[] = []
|
|
109
|
+
render(<Harness withCombobox initial={stored} onRepos={(r) => (latest = r)} />)
|
|
110
|
+
fireEvent.click(screen.getByRole("button", { name: /add repository/i }))
|
|
111
|
+
fireEvent.click(screen.getByRole("button", { name: /add repository/i }))
|
|
112
|
+
expect(new Set(latest.map((r) => r.id)).size).toBe(latest.length)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("keeps rows independent through a combobox slot, the path the dashboard uses", () => {
|
|
116
|
+
let latest: ProjectRepo[] = []
|
|
117
|
+
render(<Harness withCombobox onRepos={(r) => (latest = r)} />)
|
|
118
|
+
fireEvent.click(screen.getByRole("button", { name: /add repository/i }))
|
|
119
|
+
fireEvent.click(screen.getByRole("button", { name: /add repository/i }))
|
|
120
|
+
|
|
121
|
+
const ids = latest.map((r) => r.id)
|
|
122
|
+
fireEvent.click(screen.getByLabelText(`pick-${ids[0]}-acme/alpha`))
|
|
123
|
+
fireEvent.click(screen.getByLabelText(`pick-${ids[1]}-group/gamma`))
|
|
124
|
+
|
|
125
|
+
expect(latest.map((r) => r.project)).toEqual(["acme/alpha", "group/gamma"])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("takes each row's provider from the repository picked, not from a button", () => {
|
|
129
|
+
// Two forges connected: the row starts with no provider and learns it from the choice.
|
|
130
|
+
let latest: ProjectRepo[] = []
|
|
131
|
+
render(<Harness withCombobox onRepos={(r) => (latest = r)} />)
|
|
132
|
+
fireEvent.click(screen.getByRole("button", { name: /add repository/i }))
|
|
133
|
+
expect(latest[0].provider).toBe("")
|
|
134
|
+
|
|
135
|
+
fireEvent.click(screen.getByLabelText(`pick-${latest[0].id}-group/gamma`))
|
|
136
|
+
expect(latest[0]).toMatchObject({ provider: "gitlab", project: "group/gamma" })
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it("offers every connected provider's repositories to an unresolved row", () => {
|
|
140
|
+
let latest: ProjectRepo[] = []
|
|
141
|
+
render(<Harness withCombobox onRepos={(r) => (latest = r)} />)
|
|
142
|
+
fireEvent.click(screen.getByRole("button", { name: /add repository/i }))
|
|
143
|
+
const id = latest[0].id
|
|
144
|
+
// All three, across both forges — you look for a repository, not for a host.
|
|
145
|
+
expect(screen.getByLabelText(`pick-${id}-acme/alpha`)).toBeTruthy()
|
|
146
|
+
expect(screen.getByLabelText(`pick-${id}-group/gamma`)).toBeTruthy()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("offers a single Add button, with no provider brand on it", () => {
|
|
150
|
+
render(<Harness withCombobox />)
|
|
151
|
+
expect(screen.getAllByRole("button", { name: /^add repository$/i })).toHaveLength(1)
|
|
152
|
+
expect(screen.queryByRole("button", { name: /add github/i })).toBeNull()
|
|
153
|
+
expect(screen.queryByRole("button", { name: /add gitlab/i })).toBeNull()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it("edits only the row that was typed in", () => {
|
|
157
|
+
let latest: ProjectRepo[] = []
|
|
158
|
+
render(<Harness onRepos={(r) => (latest = r)} />)
|
|
159
|
+
|
|
160
|
+
fireEvent.click(screen.getByRole("button", { name: /add repository/i }))
|
|
161
|
+
fireEvent.click(screen.getByRole("button", { name: /add repository/i }))
|
|
162
|
+
|
|
163
|
+
const inputs = repoInputs()
|
|
164
|
+
expect(inputs).toHaveLength(2)
|
|
165
|
+
|
|
166
|
+
fireEvent.change(inputs[0], { target: { value: "acme/alpha" } })
|
|
167
|
+
expect(latest.map((r) => r.project)).toEqual(["acme/alpha", ""])
|
|
168
|
+
|
|
169
|
+
fireEvent.change(repoInputs()[1], { target: { value: "acme/beta" } })
|
|
170
|
+
expect(latest.map((r) => r.project)).toEqual(["acme/alpha", "acme/beta"])
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it("gives every added row a distinct id", () => {
|
|
174
|
+
let latest: ProjectRepo[] = []
|
|
175
|
+
render(<Harness onRepos={(r) => (latest = r)} />)
|
|
176
|
+
fireEvent.click(screen.getByRole("button", { name: /add repository/i }))
|
|
177
|
+
fireEvent.click(screen.getByRole("button", { name: /add repository/i }))
|
|
178
|
+
fireEvent.click(screen.getByRole("button", { name: /add git url/i }))
|
|
179
|
+
expect(new Set(latest.map((r) => r.id)).size).toBe(latest.length)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it("keeps rows independent when a raw git URL sits between two provider rows", () => {
|
|
183
|
+
let latest: ProjectRepo[] = []
|
|
184
|
+
render(<Harness onRepos={(r) => (latest = r)} />)
|
|
185
|
+
fireEvent.click(screen.getByRole("button", { name: /add repository/i }))
|
|
186
|
+
fireEvent.click(screen.getByRole("button", { name: /add git url/i }))
|
|
187
|
+
fireEvent.click(screen.getByRole("button", { name: /add repository/i }))
|
|
188
|
+
|
|
189
|
+
fireEvent.change(repoInputs()[1], { target: { value: "acme/beta" } })
|
|
190
|
+
expect(latest.map((r) => r.project)).toEqual(["", "", "acme/beta"])
|
|
191
|
+
})
|
|
192
|
+
})
|
|
@@ -8,30 +8,51 @@ import { Button } from "../button"
|
|
|
8
8
|
import { Input } from "../input"
|
|
9
9
|
import type { ProjectRepo } from "./types"
|
|
10
10
|
|
|
11
|
-
/** A repository the user can pick
|
|
12
|
-
export interface
|
|
11
|
+
/** A repository the user can pick, and which provider can reach it. */
|
|
12
|
+
export interface GitRepoOption {
|
|
13
|
+
/** Id of the provider that hosts it — matches `GitProvider.id` and `ProjectRepo.provider`. */
|
|
14
|
+
provider: string
|
|
13
15
|
fullName: string
|
|
14
16
|
private?: boolean
|
|
15
|
-
installationId?: number
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* A git hosting provider available to the org.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately not "the GitHub connection": this list used to model exactly one forge, so every
|
|
23
|
+
* screen built on it had GitHub in its vocabulary and a second provider could not appear without
|
|
24
|
+
* new props and a new branch. Here a provider is data — the app derives it from whatever
|
|
25
|
+
* implements git hosting — and the component only reads `operations` to decide what to offer.
|
|
26
|
+
*/
|
|
27
|
+
export interface GitProvider {
|
|
28
|
+
id: string
|
|
29
|
+
name: string
|
|
20
30
|
connected?: boolean
|
|
21
|
-
|
|
22
|
-
|
|
31
|
+
/** Accounts/namespaces reachable through it, shown so the user can tell two connections apart. */
|
|
32
|
+
accounts?: { login: string; avatarUrl?: string | null }[]
|
|
33
|
+
/** Where to send the user to connect it. The package knows no route of its own. */
|
|
34
|
+
installUrl?: string | null
|
|
35
|
+
/** What it can do, e.g. `["listRepos", "cloneCredential"]`. Read, never assumed from `id`. */
|
|
36
|
+
operations?: string[]
|
|
37
|
+
/** Brand glyph, supplied by the app. Defaults to a git branch. */
|
|
38
|
+
icon?: ReactNode
|
|
23
39
|
}
|
|
24
40
|
|
|
25
41
|
/**
|
|
26
|
-
* Renders the
|
|
42
|
+
* Renders the repository field. A slot, because a real combobox is an
|
|
27
43
|
* app-level dependency (the dashboard has a Base UI one): without it the list
|
|
28
44
|
* falls back to an `<input list>` + `<datalist>`, which needs nothing, works
|
|
29
45
|
* with a keyboard, and is honest about being a plain text field.
|
|
30
46
|
*/
|
|
31
47
|
export type RepoFieldRender = (props: {
|
|
32
48
|
value: string
|
|
33
|
-
|
|
34
|
-
|
|
49
|
+
/**
|
|
50
|
+
* `option` is set when the user **picked** an entry rather than typing free text. It carries the
|
|
51
|
+
* provider, which is how a row learns who hosts it: the choice of forge is a consequence of the
|
|
52
|
+
* repository you picked, not a decision you make first in a separate button.
|
|
53
|
+
*/
|
|
54
|
+
onChange: (value: string, option?: GitRepoOption) => void
|
|
55
|
+
options: GitRepoOption[]
|
|
35
56
|
placeholder: string
|
|
36
57
|
id: string
|
|
37
58
|
}) => ReactNode
|
|
@@ -39,17 +60,21 @@ export type RepoFieldRender = (props: {
|
|
|
39
60
|
export interface RepoListProps {
|
|
40
61
|
repos: ProjectRepo[]
|
|
41
62
|
onChange: (repos: ProjectRepo[]) => void
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
connectUrl?: string
|
|
63
|
+
/** Providers that can host repositories for this org, connected or not. */
|
|
64
|
+
gitProviders?: GitProvider[]
|
|
65
|
+
/** Repositories reachable across every connected provider. */
|
|
66
|
+
gitRepos?: GitRepoOption[]
|
|
47
67
|
renderRepoField?: RepoFieldRender
|
|
48
|
-
/** Icon for GitHub rows — the app passes its brand glyph; defaults to a git branch. */
|
|
49
|
-
githubIcon?: ReactNode
|
|
50
68
|
className?: string
|
|
51
69
|
}
|
|
52
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The pseudo-provider for a repository given as a raw clone URL: no app hosts it, the project's
|
|
73
|
+
* deploy key opens it. Kept as a literal rather than an app id because it is the *absence* of a
|
|
74
|
+
* provider, which is a different thing from a provider that happens to be missing.
|
|
75
|
+
*/
|
|
76
|
+
export const RAW_GIT = "git"
|
|
77
|
+
|
|
53
78
|
/** Human label + default workspace dir out of a raw clone URL. */
|
|
54
79
|
export function deriveFromGitUrl(url: string): { label: string; repoName: string } {
|
|
55
80
|
const path = url
|
|
@@ -72,13 +97,24 @@ const ROW_LEAD = "relative min-w-0 basis-full @min-[30rem]/repos:basis-0 @min-[3
|
|
|
72
97
|
|
|
73
98
|
let seq = 0
|
|
74
99
|
/**
|
|
75
|
-
* Identity for a row that doesn't exist server-side yet.
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* and the
|
|
100
|
+
* Identity for a row that doesn't exist server-side yet.
|
|
101
|
+
*
|
|
102
|
+
* A counter rather than `crypto.randomUUID()`: it only has to be unique among the rows of one
|
|
103
|
+
* form, and a counter is stable under SSR, where `randomUUID` would hand the server and the client
|
|
104
|
+
* two different keys.
|
|
105
|
+
*
|
|
106
|
+
* **Checked against the rows already present**, which the counter alone cannot do. These ids get
|
|
107
|
+
* *persisted*: a project saved with `repo-1` comes back as `repo-1` when it is edited, while the
|
|
108
|
+
* counter restarts at 0 on every page load — so the first row added on an edit page was handed
|
|
109
|
+
* `repo-1` again. Two rows, one id, and since every field is addressed by id, editing either one
|
|
110
|
+
* edited both. It looked like the picker leaking state between rows; it was a name collision
|
|
111
|
+
* between a stored row and a new one.
|
|
79
112
|
*/
|
|
80
|
-
function localRepoId(): string {
|
|
81
|
-
|
|
113
|
+
function localRepoId(existing: readonly ProjectRepo[]): string {
|
|
114
|
+
const taken = new Set(existing.map((r) => r.id))
|
|
115
|
+
do {
|
|
116
|
+
seq += 1
|
|
117
|
+
} while (taken.has(`repo-${seq}`))
|
|
82
118
|
return `repo-${seq}`
|
|
83
119
|
}
|
|
84
120
|
|
|
@@ -94,26 +130,37 @@ function localRepoId(): string {
|
|
|
94
130
|
export function RepoList({
|
|
95
131
|
repos,
|
|
96
132
|
onChange,
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
connectUrl,
|
|
133
|
+
gitProviders = [],
|
|
134
|
+
gitRepos = [],
|
|
100
135
|
renderRepoField,
|
|
101
|
-
githubIcon,
|
|
102
136
|
className,
|
|
103
137
|
}: RepoListProps) {
|
|
104
|
-
|
|
105
|
-
|
|
138
|
+
// A provider is only offerable if it can actually list or clone. Read from `operations` rather
|
|
139
|
+
// than from a provider id, so a forge added tomorrow shows up here with no change to this file.
|
|
140
|
+
const pickable = gitProviders.filter((p) => p.operations?.includes("cloneCredential") ?? true)
|
|
141
|
+
const connected = pickable.filter((p) => p.connected)
|
|
142
|
+
const hasRawGitRepos = repos.some((r) => r.provider === RAW_GIT)
|
|
143
|
+
|
|
144
|
+
const providerOf = (repo: ProjectRepo) => pickable.find((p) => p.id === repo.provider)
|
|
145
|
+
/**
|
|
146
|
+
* What a row can pick from. A row that hasn't been resolved to a provider yet offers **every**
|
|
147
|
+
* connected provider's repositories at once — that is the point of choosing the repository
|
|
148
|
+
* first: you look for `acme/web`, not for "which forge was that on again".
|
|
149
|
+
*/
|
|
150
|
+
const reposFor = (repo: ProjectRepo) =>
|
|
151
|
+
repo.provider ? gitRepos.filter((r) => r.provider === repo.provider) : gitRepos
|
|
106
152
|
|
|
107
153
|
const patch = (id: string, next: Partial<ProjectRepo>) =>
|
|
108
154
|
onChange(repos.map((r) => (r.id === id ? { ...r, ...next } : r)))
|
|
109
155
|
|
|
110
|
-
function setProject(repo: ProjectRepo, value: string) {
|
|
156
|
+
function setProject(repo: ProjectRepo, value: string, option?: GitRepoOption) {
|
|
111
157
|
const repoName = value.split("/")[1] ?? value
|
|
112
158
|
patch(repo.id, {
|
|
113
159
|
project: value,
|
|
114
|
-
//
|
|
115
|
-
// it
|
|
116
|
-
|
|
160
|
+
// A picked repository says who hosts it. Typed free text does not, so the row keeps whatever
|
|
161
|
+
// provider it had — re-deriving it from a half-typed string would flip the host under the
|
|
162
|
+
// user mid-keystroke.
|
|
163
|
+
...(option ? { provider: option.provider } : {}),
|
|
117
164
|
...(repo.workspacePathTouched ? {} : { workspacePath: repoName }),
|
|
118
165
|
})
|
|
119
166
|
}
|
|
@@ -149,7 +196,7 @@ export function RepoList({
|
|
|
149
196
|
// the repository takes a full row of its own until there's room, and the
|
|
150
197
|
// path + bin share the next one.
|
|
151
198
|
<div key={repo.id} className="flex flex-wrap items-center gap-2">
|
|
152
|
-
{repo.provider ===
|
|
199
|
+
{repo.provider === RAW_GIT ? (
|
|
153
200
|
<div className={ROW_LEAD}>
|
|
154
201
|
<GitBranchIcon className="absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
155
202
|
<Input
|
|
@@ -159,12 +206,12 @@ export function RepoList({
|
|
|
159
206
|
onChange={(e) => setCloneUrl(repo, e.target.value)}
|
|
160
207
|
/>
|
|
161
208
|
</div>
|
|
162
|
-
) : renderRepoField &&
|
|
209
|
+
) : renderRepoField && reposFor(repo).length > 0 ? (
|
|
163
210
|
<div className={ROW_LEAD}>
|
|
164
211
|
{renderRepoField({
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
212
|
+
value: repo.project,
|
|
213
|
+
onChange: (value, option) => setProject(repo, value, option),
|
|
214
|
+
options: reposFor(repo),
|
|
168
215
|
placeholder: "Search or type owner/repo…",
|
|
169
216
|
id: repo.id,
|
|
170
217
|
})}
|
|
@@ -172,18 +219,18 @@ export function RepoList({
|
|
|
172
219
|
) : (
|
|
173
220
|
<div className={cn(ROW_LEAD, "relative")}>
|
|
174
221
|
<span className="absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground">
|
|
175
|
-
{
|
|
222
|
+
{providerOf(repo)?.icon ?? <GitBranchIcon className="h-3.5 w-3.5" />}
|
|
176
223
|
</span>
|
|
177
224
|
<Input
|
|
178
225
|
className="pl-8"
|
|
179
|
-
list={
|
|
226
|
+
list={reposFor(repo).length > 0 ? `repos-${repo.id}` : undefined}
|
|
180
227
|
placeholder="owner/repo"
|
|
181
228
|
value={repo.project}
|
|
182
229
|
onChange={(e) => setProject(repo, e.target.value)}
|
|
183
230
|
/>
|
|
184
|
-
{
|
|
231
|
+
{reposFor(repo).length > 0 && (
|
|
185
232
|
<datalist id={`repos-${repo.id}`}>
|
|
186
|
-
{
|
|
233
|
+
{reposFor(repo).map((r) => (
|
|
187
234
|
<option key={r.fullName} value={r.fullName} />
|
|
188
235
|
))}
|
|
189
236
|
</datalist>
|
|
@@ -223,26 +270,42 @@ export function RepoList({
|
|
|
223
270
|
</div>
|
|
224
271
|
))}
|
|
225
272
|
|
|
273
|
+
{/* **One** button, and a neutral glyph on it.
|
|
274
|
+
Not one per provider: the forge is a property of the repository you pick, not a decision
|
|
275
|
+
to make before you know which repository you want. And a brand glyph on a generic button
|
|
276
|
+
claims a host the row may not end up having — with two providers connected, "Add
|
|
277
|
+
repository" under a GitHub mark is simply wrong half the time. The row picks up its
|
|
278
|
+
provider, and its icon, from the entry chosen in the field. */}
|
|
226
279
|
<div className="flex flex-wrap gap-2">
|
|
280
|
+
{connected.length > 0 && (
|
|
281
|
+
<Button
|
|
282
|
+
type="button"
|
|
283
|
+
variant="outline"
|
|
284
|
+
size="sm"
|
|
285
|
+
onClick={() =>
|
|
286
|
+
onChange([
|
|
287
|
+
...repos,
|
|
288
|
+
{
|
|
289
|
+
id: localRepoId(repos),
|
|
290
|
+
// Left empty when several providers are connected, so the field offers all of
|
|
291
|
+
// them; pre-filled when there is only one, because there is nothing to choose.
|
|
292
|
+
provider: connected.length === 1 ? connected[0].id : "",
|
|
293
|
+
project: "",
|
|
294
|
+
workspacePath: "",
|
|
295
|
+
},
|
|
296
|
+
])
|
|
297
|
+
}
|
|
298
|
+
>
|
|
299
|
+
<GitBranchIcon className="mr-1 h-4 w-4" />
|
|
300
|
+
Add repository
|
|
301
|
+
</Button>
|
|
302
|
+
)}
|
|
227
303
|
<Button
|
|
228
304
|
type="button"
|
|
229
305
|
variant="outline"
|
|
230
306
|
size="sm"
|
|
231
307
|
onClick={() =>
|
|
232
|
-
onChange([...repos, { id: localRepoId(), provider:
|
|
233
|
-
}
|
|
234
|
-
>
|
|
235
|
-
<span className="mr-1 flex h-4 w-4 items-center justify-center">
|
|
236
|
-
{githubIcon ?? <GitBranchIcon className="h-4 w-4" />}
|
|
237
|
-
</span>
|
|
238
|
-
Add GitHub repo
|
|
239
|
-
</Button>
|
|
240
|
-
<Button
|
|
241
|
-
type="button"
|
|
242
|
-
variant="outline"
|
|
243
|
-
size="sm"
|
|
244
|
-
onClick={() =>
|
|
245
|
-
onChange([...repos, { id: localRepoId(), provider: "git", project: "", workspacePath: "", cloneUrl: "" }])
|
|
308
|
+
onChange([...repos, { id: localRepoId(repos), provider: RAW_GIT, project: "", workspacePath: "", cloneUrl: "" }])
|
|
246
309
|
}
|
|
247
310
|
>
|
|
248
311
|
<GitBranchIcon className="mr-1 h-4 w-4" />
|
|
@@ -252,23 +315,29 @@ export function RepoList({
|
|
|
252
315
|
|
|
253
316
|
<p className="text-xs text-muted-foreground">
|
|
254
317
|
Cloned into <code>/workspace/<path></code> at worker startup, on the branch set here (empty = the
|
|
255
|
-
remote's default).
|
|
256
|
-
{connectUrl &&
|
|
257
|
-
(github?.connected ? (
|
|
258
|
-
<a href={connectUrl} className="underline underline-offset-2">
|
|
259
|
-
Reconnect GitHub
|
|
260
|
-
</a>
|
|
261
|
-
) : (
|
|
262
|
-
<>
|
|
263
|
-
<a href={connectUrl} className="underline underline-offset-2">
|
|
264
|
-
Connect GitHub
|
|
265
|
-
</a>{" "}
|
|
266
|
-
to enable private repos for this organization.
|
|
267
|
-
</>
|
|
268
|
-
))}
|
|
318
|
+
remote's default).
|
|
269
319
|
</p>
|
|
270
320
|
|
|
271
|
-
{
|
|
321
|
+
{/* Providers the org could connect but hasn't. Named, so the offer is "connect GitLab",
|
|
322
|
+
not a generic prompt that leaves the user guessing what is even available. */}
|
|
323
|
+
{pickable.some((p) => !p.connected && p.installUrl) && (
|
|
324
|
+
<p className="text-xs text-muted-foreground">
|
|
325
|
+
Connect{" "}
|
|
326
|
+
{pickable
|
|
327
|
+
.filter((p) => !p.connected && p.installUrl)
|
|
328
|
+
.map((provider, index, list) => (
|
|
329
|
+
<span key={provider.id}>
|
|
330
|
+
{index > 0 && (index === list.length - 1 ? " or " : ", ")}
|
|
331
|
+
<a href={provider.installUrl!} className="underline underline-offset-2">
|
|
332
|
+
{provider.name}
|
|
333
|
+
</a>
|
|
334
|
+
</span>
|
|
335
|
+
))}{" "}
|
|
336
|
+
to use private repositories in this organization.
|
|
337
|
+
</p>
|
|
338
|
+
)}
|
|
339
|
+
|
|
340
|
+
{hasRawGitRepos && (
|
|
272
341
|
<p className="rounded-md border border-dashed p-3 text-xs text-muted-foreground">
|
|
273
342
|
Git URL repos (GitLab, Gitea, Bitbucket, self-hosted…) are cloned with this project's SSH{" "}
|
|
274
343
|
<strong>deploy key</strong>. After saving, register the public key shown on the project page as a{" "}
|
|
@@ -277,39 +346,37 @@ export function RepoList({
|
|
|
277
346
|
</p>
|
|
278
347
|
)}
|
|
279
348
|
|
|
280
|
-
{
|
|
349
|
+
{connected.some((p) => p.accounts?.length) && (
|
|
281
350
|
<div className="space-y-2 rounded-md border border-dashed p-3">
|
|
282
|
-
<p className="text-xs font-medium">
|
|
351
|
+
<p className="text-xs font-medium">Reachable accounts:</p>
|
|
283
352
|
<div className="flex flex-wrap gap-2">
|
|
284
|
-
{
|
|
285
|
-
|
|
353
|
+
{connected.flatMap((provider) =>
|
|
354
|
+
(provider.accounts ?? []).map((account) => (
|
|
355
|
+
<span key={`${provider.id}:${account.login}`} className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs">
|
|
286
356
|
{account.avatarUrl && (
|
|
287
357
|
<img src={account.avatarUrl} alt="" className="h-4 w-4 rounded-sm" />
|
|
288
358
|
)}
|
|
289
359
|
{account.login}
|
|
290
360
|
</span>
|
|
291
|
-
|
|
361
|
+
)),
|
|
362
|
+
)}
|
|
292
363
|
</div>
|
|
293
|
-
{
|
|
364
|
+
{/* "Add another account" points at the provider's own install flow — the package no
|
|
365
|
+
longer builds a github.com URL out of an app slug it had no business knowing. */}
|
|
366
|
+
{connected.filter((p) => p.installUrl).length > 0 && (
|
|
294
367
|
<p className="text-xs text-muted-foreground">
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
<a href={connectUrl} className="underline underline-offset-2">
|
|
308
|
-
reconnect
|
|
309
|
-
</a>
|
|
310
|
-
</>
|
|
311
|
-
)}
|
|
312
|
-
.
|
|
368
|
+
Add another account on{" "}
|
|
369
|
+
{connected
|
|
370
|
+
.filter((p) => p.installUrl)
|
|
371
|
+
.map((provider, index, list) => (
|
|
372
|
+
<span key={provider.id}>
|
|
373
|
+
{index > 0 && (index === list.length - 1 ? " or " : ", ")}
|
|
374
|
+
<a href={provider.installUrl!} className="underline underline-offset-2">
|
|
375
|
+
{provider.name}
|
|
376
|
+
</a>
|
|
377
|
+
</span>
|
|
378
|
+
))}{" "}
|
|
379
|
+
to reach more repositories.
|
|
313
380
|
</p>
|
|
314
381
|
)}
|
|
315
382
|
</div>
|
|
@@ -17,8 +17,13 @@ import type { SectionAccent } from "./form-section"
|
|
|
17
17
|
export interface ProjectRepo {
|
|
18
18
|
/** Client-side identity, stable across re-renders. Not the API's id. */
|
|
19
19
|
id: string
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Which git provider hosts this repo — the id of an integration that implements git hosting
|
|
22
|
+
* (`"github"`, `"gitlab"`, …), or `"git"` for a raw clone URL opened with the project's deploy
|
|
23
|
+
* key. A `string` rather than a union: the set of forges is the *deployment's*, not this
|
|
24
|
+
* package's, and hardcoding it here is what kept a second one from ever appearing.
|
|
25
|
+
*/
|
|
26
|
+
provider: string
|
|
22
27
|
/** `owner/repo` for GitHub, a human label for a raw URL. */
|
|
23
28
|
project: string
|
|
24
29
|
/** Directory under `/workspace`. Auto-derived from the repo name until the user edits it. */
|
|
@@ -28,8 +33,6 @@ export interface ProjectRepo {
|
|
|
28
33
|
* Without it, picking a repo silently overwrites a path the user had set.
|
|
29
34
|
*/
|
|
30
35
|
workspacePathTouched?: boolean
|
|
31
|
-
/** Which GitHub App installation grants access (an org can connect several). */
|
|
32
|
-
installationId?: number
|
|
33
36
|
/** Raw clone URL, `git` provider only. */
|
|
34
37
|
cloneUrl?: string
|
|
35
38
|
/**
|
|
@@ -26,7 +26,7 @@ export interface WorkerCardWorker {
|
|
|
26
26
|
name?: string | null
|
|
27
27
|
/** Fallback identity when `name` is empty — renders as "Workspace #3". */
|
|
28
28
|
index?: number | null
|
|
29
|
-
/** Lifecycle state: `provisioning` | `pulling` | `starting` | `setup` | `ready` | `stopping` | `stopped` | `deleting` | `error`. */
|
|
29
|
+
/** Lifecycle state: `provisioning` | `building` | `pulling` | `starting` | `setup` | `ready` | `stopping` | `stopped` | `deleting` | `error`. */
|
|
30
30
|
state?: string | null
|
|
31
31
|
/** Docker-level state: `running` | `stopped` | `exited` | `created` | `not_found` | `error`. */
|
|
32
32
|
dockerState?: string | null
|
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
// This module is the union of both, plus the transient states (`pulling`,
|
|
7
7
|
// `stopping`, `deleting`) that only ever existed as ad-hoc `if`s above the table.
|
|
8
8
|
//
|
|
9
|
+
// `building` is the odd one out: it is the state a *control plane* reports, and
|
|
10
|
+
// the only one here that Spunto Lite names and the dashboard does not. Both wait
|
|
11
|
+
// for a project image before a container exists — Cloud does it inside
|
|
12
|
+
// `provisioning` and rebuilds the fact client-side by joining the project's image
|
|
13
|
+
// builds, Lite says it in the field. It is deliberately not folded into
|
|
14
|
+
// `pulling`: building an image and pulling one are different waits, and only one
|
|
15
|
+
// of them has a log worth opening.
|
|
16
|
+
//
|
|
9
17
|
// No "use client": pure functions and hookless components, so a React Server
|
|
10
18
|
// Component can call them.
|
|
11
19
|
|
|
@@ -21,6 +29,7 @@ export type WorkerStatusKey =
|
|
|
21
29
|
| "error"
|
|
22
30
|
| "pending"
|
|
23
31
|
| "setup"
|
|
32
|
+
| "building"
|
|
24
33
|
| "pulling"
|
|
25
34
|
| "stopping"
|
|
26
35
|
| "deleting"
|
|
@@ -79,6 +88,7 @@ export const workerStatusConfig: Record<WorkerStatusKey, WorkerStatusConfig> = {
|
|
|
79
88
|
},
|
|
80
89
|
pending: BUSY,
|
|
81
90
|
setup: { ...BUSY, label: "Setting up…" },
|
|
91
|
+
building: { ...BUSY, label: "Building image…" },
|
|
82
92
|
pulling: { ...BUSY, label: "Pulling image…" },
|
|
83
93
|
stopping: { ...BUSY, label: "Stopping…" },
|
|
84
94
|
deleting: { ...BUSY, label: "Deleting…" },
|
|
@@ -99,7 +109,7 @@ const SETUP_STATES = new Set(["provisioning", "starting", "setup"])
|
|
|
99
109
|
* Worker snapshot → one status, tolerant of shapes it has never seen.
|
|
100
110
|
*
|
|
101
111
|
* Order matters: the *lifecycle* state wins whenever it describes an action in
|
|
102
|
-
* flight (deleting, stopping, pulling, setting up), because docker still reports
|
|
112
|
+
* flight (deleting, stopping, building, pulling, setting up), because docker still reports
|
|
103
113
|
* the container as `running` throughout — showing "Running" while a worker is
|
|
104
114
|
* being deleted is the bug this ordering exists to prevent. Past that, the
|
|
105
115
|
* docker state is the truth. Apps with a single `state` field (Spunto Lite) fall
|
|
@@ -120,6 +130,7 @@ export function resolveWorkerStatus(worker: WorkerCardWorker): WorkerStatus {
|
|
|
120
130
|
|
|
121
131
|
if (state === "deleting") return of("deleting")
|
|
122
132
|
if (state === "stopping") return of("stopping")
|
|
133
|
+
if (state === "building") return of("building", { settingUp: true })
|
|
123
134
|
if (state === "pulling") return of("pulling", { settingUp: true })
|
|
124
135
|
if (state && SETUP_STATES.has(state)) return of("setup", { settingUp: true })
|
|
125
136
|
|