@spunto/design-system 0.22.0 → 0.24.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.
@@ -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, GithubRepoOption, GithubConnection, RepoFieldRender } from "./repo-list"
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 GithubConnection, type GithubRepoOption, type RepoFieldRender } from "./repo-list"
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
- githubRepos?: GithubRepoOption[]
119
- github?: GithubConnection
120
- connectUrl?: string
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
- githubRepos,
198
- github,
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
- githubRepos={githubRepos}
374
- github={github}
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 from a connected GitHub account. */
12
- export interface GithubRepoOption {
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
- /** State of the org's GitHub App connection, as far as this list needs it. */
19
- export interface GithubConnection {
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
- appSlug?: string
22
- installedAccounts?: { login: string; avatarUrl?: string | null }[]
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 GitHub repository field. A slot, because a real combobox is an
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
- onChange: (value: string) => void
34
- options: GithubRepoOption[]
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
- /** Repositories reachable through the connected GitHub App. */
43
- githubRepos?: GithubRepoOption[]
44
- github?: GithubConnection
45
- /** Where "Connect GitHub" points. The package knows no route of its own. */
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. A counter rather than
76
- * `crypto.randomUUID()`: it only has to be unique among the rows of one form,
77
- * and a counter is stable under SSR, where `randomUUID` would hand the server
78
- * and the client two different keys.
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
- seq += 1
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
- githubRepos = [],
98
- github,
99
- connectUrl,
133
+ gitProviders = [],
134
+ gitRepos = [],
100
135
  renderRepoField,
101
- githubIcon,
102
136
  className,
103
137
  }: RepoListProps) {
104
- const hasGithubRepos = githubRepos.length > 0
105
- const hasGitRepos = repos.some((r) => r.provider === "git")
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
- // Tag the row with the installation that grants access, so a worker clones
115
- // it with the right token when the org connected several GitHub accounts.
116
- installationId: githubRepos.find((r) => r.fullName === value)?.installationId,
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 === "git" ? (
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 && hasGithubRepos ? (
209
+ ) : renderRepoField && reposFor(repo).length > 0 ? (
163
210
  <div className={ROW_LEAD}>
164
211
  {renderRepoField({
165
- value: repo.project,
166
- onChange: (value) => setProject(repo, value),
167
- options: githubRepos,
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
- {githubIcon ?? <GitBranchIcon className="h-3.5 w-3.5" />}
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={hasGithubRepos ? `repos-${repo.id}` : undefined}
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
- {hasGithubRepos && (
231
+ {reposFor(repo).length > 0 && (
185
232
  <datalist id={`repos-${repo.id}`}>
186
- {githubRepos.map((r) => (
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: "github", project: "", workspacePath: "" }])
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/&lt;path&gt;</code> at worker startup, on the branch set here (empty = the
255
- remote&apos;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&apos;s default).
269
319
  </p>
270
320
 
271
- {hasGitRepos && (
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&apos;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
- {github?.connected && github.installedAccounts && github.installedAccounts.length > 0 && (
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">GitHub App installed on:</p>
351
+ <p className="text-xs font-medium">Reachable accounts:</p>
283
352
  <div className="flex flex-wrap gap-2">
284
- {github.installedAccounts.map((account) => (
285
- <span key={account.login} className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs">
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
- {github.appSlug && (
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
- <a
296
- href={`https://github.com/apps/${github.appSlug}/installations/new`}
297
- target="_blank"
298
- rel="noopener noreferrer"
299
- className="underline underline-offset-2"
300
- >
301
- Install on another account
302
- </a>{" "}
303
- to access more repos
304
- {connectUrl && (
305
- <>
306
- , then{" "}
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
- /** `github` resolves through the connected GitHub App; `git` clones a raw URL with the deploy key. */
21
- provider: "github" | "git"
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
  /**