Unified · Split
| @@ -580,6 +580,13 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { · 579 unmodified lines | ||
| 580 | 580 | cwd: input.cwd, |
| 581 | 581 | args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], |
| 582 | 582 | }).pipe(Effect.map((result) => JSON.parse(result.stdout))), |
| 583 | + createRepository: (input) => | |
| 584 | + Effect.fail( | |
| 585 | + new GitHubCliError({ | |
| 586 | + operation: "createRepository", | |
| 587 | + detail: `Unexpected repository create: ${input.repository}`, | |
| 588 | + }), | |
| 589 | + ), | |
| 583 | 590 | checkoutPullRequest: (input) => |
| 584 | 591 | execute({ |
| 585 | 592 | cwd: input.cwd, |
| @@ -117,6 +117,10 @@ import { · 116 unmodified lines | ||
| 117 | 117 | } from "./vcs/VcsDriverRegistry.ts"; |
| 118 | 118 | import { layer as VcsProvisioningServiceLayer } from "./vcs/VcsProvisioningService.ts"; |
| 119 | 119 | import { layer as GitWorkflowServiceLayer } from "./git/GitWorkflowService.ts"; |
| 120 | +import { | |
| 121 | + SourceControlRepositoryService, | |
| 122 | + type SourceControlRepositoryServiceShape, | |
| 123 | +} from "./sourceControl/SourceControlRepositoryService.ts"; | |
| 120 | 124 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; |
| 121 | 125 | import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; |
| 122 | 126 | |
| @@ -330,6 +334,7 @@ const buildAppUnderTest = (options?: { · 207 unmodified lines | ||
| 330 | 334 | vcsDriverRegistry?: Partial<VcsDriverRegistryShape>; |
| 331 | 335 | gitVcsDriver?: Partial<GitVcsDriver.GitVcsDriverShape>; |
| 332 | 336 | gitManager?: Partial<GitManagerShape>; |
| 337 | + sourceControlRepositoryService?: Partial<SourceControlRepositoryServiceShape>; | |
| 333 | 338 | vcsStatusBroadcaster?: Partial<VcsStatusBroadcasterShape>; |
| 334 | 339 | projectSetupScriptRunner?: Partial<ProjectSetupScriptRunnerShape>; |
| 335 | 340 | terminalManager?: Partial<TerminalManagerShape>; |
| @@ -545,6 +550,11 @@ const buildAppUnderTest = (options?: { · 209 unmodified lines | ||
| 545 | 550 | Layer.provide(gitVcsDriverLayer), |
| 546 | 551 | Layer.provide(gitWorkflowLayer), |
| 547 | 552 | Layer.provide(vcsProvisioningLayer), |
| 553 | + Layer.provide( | |
| 554 | + Layer.mock(SourceControlRepositoryService)({ | |
| 555 | + ...options?.layers?.sourceControlRepositoryService, | |
| 556 | + }), | |
| 557 | + ), | |
| 548 | 558 | Layer.provideMerge(vcsStatusBroadcasterLayer), |
| 549 | 559 | Layer.provide( |
| 550 | 560 | Layer.mock(ProjectSetupScriptRunner)({ |
| @@ -54,6 +54,7 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; · 53 unmodified lines | ||
| 54 | 54 | import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; |
| 55 | 55 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 56 | 56 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; |
| 57 | +import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; | |
| 57 | 58 | import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; |
| 58 | 59 | import { ObservabilityLive } from "./observability/Layers/Observability.ts"; |
| 59 | 60 | import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; |
| @@ -161,15 +162,15 @@ const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( · 101 unmodified lines | ||
| 161 | 162 | Layer.provide(VcsProjectConfig.layer), |
| 162 | 163 | ); |
| 163 | 164 | |
| 165 | +const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe( | |
| 166 | + Layer.provide(Layer.mergeAll(GitHubCli.layer, GitLabCli.layer)), | |
| 167 | + Layer.provideMerge(VcsDriverRegistryLayerLive), | |
| 168 | +); | |
| 169 | + | |
| 164 | 170 | const GitManagerLayerLive = GitManager.layer.pipe( |
| 165 | 171 | Layer.provideMerge(ProjectSetupScriptRunnerLive), |
| 166 | 172 | Layer.provideMerge(GitVcsDriver.layer), |
| 167 | − Layer.provideMerge( | |
| 168 | − SourceControlProviderRegistry.layer.pipe( | |
| 169 | − Layer.provide(Layer.mergeAll(GitHubCli.layer, GitLabCli.layer)), | |
| 170 | − Layer.provideMerge(VcsDriverRegistryLayerLive), | |
| 171 | − ), | |
| 172 | − ), | |
| 173 | + Layer.provideMerge(SourceControlProviderRegistryLayerLive), | |
| 173 | 174 | Layer.provideMerge(TextGeneration.layer), |
| 174 | 175 | ); |
| 175 | 176 | |
| @@ -183,11 +184,17 @@ const GitWorkflowLayerLive = GitWorkflowService.layer.pipe( · 7 unmodified lines | ||
| 183 | 184 | Layer.provideMerge(GitLayerLive), |
| 184 | 185 | ); |
| 185 | 186 | |
| 187 | +const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.layer.pipe( | |
| 188 | + Layer.provideMerge(GitVcsDriver.layer), | |
| 189 | + Layer.provideMerge(SourceControlProviderRegistryLayerLive), | |
| 190 | +); | |
| 191 | + | |
| 186 | 192 | const VcsLayerLive = Layer.empty.pipe( |
| 187 | 193 | Layer.provideMerge(VcsProjectConfig.layer), |
| 188 | 194 | Layer.provideMerge(VcsDriverRegistryLayerLive), |
| 189 | 195 | Layer.provideMerge(VcsProvisioningService.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))), |
| 190 | 196 | Layer.provideMerge(GitWorkflowLayerLive), |
| 197 | + Layer.provideMerge(SourceControlRepositoryServiceLayerLive), | |
| 191 | 198 | Layer.provideMerge(VcsStatusBroadcaster.layer.pipe(Layer.provide(GitWorkflowLayerLive))), |
| 192 | 199 | ); |
| 193 | 200 | |
| @@ -211,6 +211,58 @@ describe("GitHubCli.layer", () => { · 210 unmodified lines | ||
| 211 | 211 | }).pipe(Effect.provide(layer)), |
| 212 | 212 | ); |
| 213 | 213 | |
| 214 | + it.effect("creates repositories and parses clone URLs from create output", () => | |
| 215 | + Effect.gen(function* () { | |
| 216 | + mockRun.mockReturnValueOnce( | |
| 217 | + Effect.succeed( | |
| 218 | + processOutput( | |
| 219 | + "✓ Created repository octocat/codething-mvp on github.com\nhttps://github.com/octocat/codething-mvp\n", | |
| 220 | + ), | |
| 221 | + ), | |
| 222 | + ); | |
| 223 | + | |
| 224 | + const gh = yield* GitHubCli.GitHubCli; | |
| 225 | + const result = yield* gh.createRepository({ | |
| 226 | + cwd: "/repo", | |
| 227 | + repository: "octocat/codething-mvp", | |
| 228 | + visibility: "private", | |
| 229 | + }); | |
| 230 | + | |
| 231 | + assert.deepStrictEqual(result, { | |
| 232 | + nameWithOwner: "octocat/codething-mvp", | |
| 233 | + url: "https://github.com/octocat/codething-mvp", | |
| 234 | + sshUrl: "git@github.com:octocat/codething-mvp.git", | |
| 235 | + }); | |
| 236 | + expect(mockRun).toHaveBeenCalledTimes(1); | |
| 237 | + expect(mockRun).toHaveBeenNthCalledWith(1, { | |
| 238 | + operation: "GitHubCli.execute", | |
| 239 | + command: "gh", | |
| 240 | + args: ["repo", "create", "octocat/codething-mvp", "--private"], | |
| 241 | + cwd: "/repo", | |
| 242 | + timeoutMs: 30_000, | |
| 243 | + }); | |
| 244 | + }).pipe(Effect.provide(layer)), | |
| 245 | + ); | |
| 246 | + | |
| 247 | + it.effect("falls back to constructed URLs when create output omits a URL", () => | |
| 248 | + Effect.gen(function* () { | |
| 249 | + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); | |
| 250 | + | |
| 251 | + const gh = yield* GitHubCli.GitHubCli; | |
| 252 | + const result = yield* gh.createRepository({ | |
| 253 | + cwd: "/repo", | |
| 254 | + repository: "octocat/codething-mvp", | |
| 255 | + visibility: "private", | |
| 256 | + }); | |
| 257 | + | |
| 258 | + assert.deepStrictEqual(result, { | |
| 259 | + nameWithOwner: "octocat/codething-mvp", | |
| 260 | + url: "https://github.com/octocat/codething-mvp", | |
| 261 | + sshUrl: "git@github.com:octocat/codething-mvp.git", | |
| 262 | + }); | |
| 263 | + }).pipe(Effect.provide(layer)), | |
| 264 | + ); | |
| 265 | + | |
| 214 | 266 | it.effect("surfaces a friendly error when the pull request is not found", () => |
| 215 | 267 | Effect.gen(function* () { |
| 216 | 268 | mockRun.mockReturnValueOnce( |
| @@ -1,6 +1,10 @@ | ||
| 1 | 1 | import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"; |
| 2 | 2 | |
| 3 | −import { TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts"; | |
| 3 | +import { | |
| 4 | + TrimmedNonEmptyString, | |
| 5 | + type SourceControlRepositoryVisibility, | |
| 6 | + type VcsError, | |
| 7 | +} from "@t3tools/contracts"; | |
| 4 | 8 | |
| 5 | 9 | import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; |
| 6 | 10 | import { |
| @@ -62,6 +66,12 @@ export interface GitHubCliShape { · 55 unmodified lines | ||
| 62 | 66 | readonly repository: string; |
| 63 | 67 | }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>; |
| 64 | 68 | |
| 69 | + readonly createRepository: (input: { | |
| 70 | + readonly cwd: string; | |
| 71 | + readonly repository: string; | |
| 72 | + readonly visibility: SourceControlRepositoryVisibility; | |
| 73 | + }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>; | |
| 74 | + | |
| 65 | 75 | readonly createPullRequest: (input: { |
| 66 | 76 | readonly cwd: string; |
| 67 | 77 | readonly baseBranch: string; |
| @@ -160,6 +170,43 @@ function normalizeRepositoryCloneUrls( · 92 unmodified lines | ||
| 160 | 170 | }; |
| 161 | 171 | } |
| 162 | 172 | |
| 173 | +/** | |
| 174 | + * `gh repo create` prints the canonical URL of the new repository on stdout | |
| 175 | + * (e.g. `https://github.com/owner/repo`). Reading it back here avoids a | |
| 176 | + * follow-up `gh repo view`, which can race GitHub's GraphQL eventual | |
| 177 | + * consistency window and falsely report the just-created repo as missing. | |
| 178 | + */ | |
| 179 | +function deriveRepositoryCloneUrlsFromCreateOutput( | |
| 180 | + stdout: string, | |
| 181 | + repository: string, | |
| 182 | +): GitHubRepositoryCloneUrls { | |
| 183 | + const fallbackHost = "github.com"; | |
| 184 | + const match = stdout.match(/https?:\/\/[^\s]+/); | |
| 185 | + if (match) { | |
| 186 | + const cleaned = match[0].replace(/\.git$/, ""); | |
| 187 | + try { | |
| 188 | + const parsed = new URL(cleaned); | |
| 189 | + const pathname = parsed.pathname.replace(/^\/+|\/+$/g, ""); | |
| 190 | + const segments = pathname.split("/").filter(Boolean); | |
| 191 | + if (segments.length === 2) { | |
| 192 | + const nameWithOwner = `${segments[0]}/${segments[1]}`; | |
| 193 | + return { | |
| 194 | + nameWithOwner, | |
| 195 | + url: `${parsed.origin}/${nameWithOwner}`, | |
| 196 | + sshUrl: `git@${parsed.host}:${nameWithOwner}.git`, | |
| 197 | + }; | |
| 198 | + } | |
| 199 | + } catch { | |
| 200 | + // Fall through to the input-derived defaults below. | |
| 201 | + } | |
| 202 | + } | |
| 203 | + return { | |
| 204 | + nameWithOwner: repository, | |
| 205 | + url: `https://${fallbackHost}/${repository}`, | |
| 206 | + sshUrl: `git@${fallbackHost}:${repository}.git`, | |
| 207 | + }; | |
| 208 | +} | |
| 209 | + | |
| 163 | 210 | function decodeGitHubJson<S extends Schema.Top>( |
| 164 | 211 | raw: string, |
| 165 | 212 | schema: S, |
| @@ -281,6 +328,15 @@ export const make = Effect.fn("makeGitHubCli")(function* () { · 115 unmodified lines | ||
| 281 | 328 | ), |
| 282 | 329 | Effect.map(normalizeRepositoryCloneUrls), |
| 283 | 330 | ), |
| 331 | + createRepository: (input) => | |
| 332 | + execute({ | |
| 333 | + cwd: input.cwd, | |
| 334 | + args: ["repo", "create", input.repository, `--${input.visibility}`], | |
| 335 | + }).pipe( | |
| 336 | + Effect.map((result) => | |
| 337 | + deriveRepositoryCloneUrlsFromCreateOutput(result.stdout, input.repository), | |
| 338 | + ), | |
| 339 | + ), | |
| 284 | 340 | createPullRequest: (input) => |
| 285 | 341 | execute({ |
| 286 | 342 | cwd: input.cwd, |
| @@ -130,6 +130,10 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { · 129 unmodified lines | ||
| 130 | 130 | github |
| 131 | 131 | .getRepositoryCloneUrls(input) |
| 132 | 132 | .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), |
| 133 | + createRepository: (input) => | |
| 134 | + github | |
| 135 | + .createRepository(input) | |
| 136 | + .pipe(Effect.mapError((error) => providerError("createRepository", error))), | |
| 133 | 137 | getDefaultBranch: (input) => |
| 134 | 138 | github |
| 135 | 139 | .getDefaultBranch(input) |
| @@ -175,7 +175,7 @@ layer("GitLabCli.layer", (it) => { · 174 unmodified lines | ||
| 175 | 175 | |
| 176 | 176 | assert.deepStrictEqual(result, { |
| 177 | 177 | nameWithOwner: "octocat/t3code", |
| 178 | − url: "https://gitlab.com/octocat/t3code.git", | |
| 178 | + url: "https://gitlab.com/octocat/t3code", | |
| 179 | 179 | sshUrl: "git@gitlab.com:octocat/t3code.git", |
| 180 | 180 | }); |
| 181 | 181 | }), |
| @@ -217,6 +217,67 @@ layer("GitLabCli.layer", (it) => { · 35 unmodified lines | ||
| 217 | 217 | }), |
| 218 | 218 | ); |
| 219 | 219 | |
| 220 | + it.effect("creates repositories under an explicit namespace", () => | |
| 221 | + Effect.gen(function* () { | |
| 222 | + mockedRun | |
| 223 | + .mockReturnValueOnce(Effect.succeed(processOutput(JSON.stringify({ id: 1234 })))) | |
| 224 | + .mockReturnValueOnce( | |
| 225 | + Effect.succeed( | |
| 226 | + processOutput( | |
| 227 | + JSON.stringify({ | |
| 228 | + path_with_namespace: "octocat/t3code", | |
| 229 | + web_url: "https://gitlab.com/octocat/t3code", | |
| 230 | + http_url_to_repo: "https://gitlab.com/octocat/t3code.git", | |
| 231 | + ssh_url_to_repo: "git@gitlab.com:octocat/t3code.git", | |
| 232 | + }), | |
| 233 | + ), | |
| 234 | + ), | |
| 235 | + ); | |
| 236 | + | |
| 237 | + const glab = yield* GitLabCli.GitLabCli; | |
| 238 | + const result = yield* glab.createRepository({ | |
| 239 | + cwd: "/repo", | |
| 240 | + repository: "octocat/t3code", | |
| 241 | + visibility: "public", | |
| 242 | + }); | |
| 243 | + | |
| 244 | + assert.deepStrictEqual(result, { | |
| 245 | + nameWithOwner: "octocat/t3code", | |
| 246 | + url: "https://gitlab.com/octocat/t3code", | |
| 247 | + sshUrl: "git@gitlab.com:octocat/t3code.git", | |
| 248 | + }); | |
| 249 | + expect(mockedRun).toHaveBeenNthCalledWith( | |
| 250 | + 1, | |
| 251 | + expect.objectContaining({ | |
| 252 | + command: "glab", | |
| 253 | + cwd: "/repo", | |
| 254 | + args: ["api", "namespaces/octocat"], | |
| 255 | + }), | |
| 256 | + ); | |
| 257 | + expect(mockedRun).toHaveBeenNthCalledWith( | |
| 258 | + 2, | |
| 259 | + expect.objectContaining({ | |
| 260 | + command: "glab", | |
| 261 | + cwd: "/repo", | |
| 262 | + args: [ | |
| 263 | + "api", | |
| 264 | + "--method", | |
| 265 | + "POST", | |
| 266 | + "projects", | |
| 267 | + "--raw-field", | |
| 268 | + "path=t3code", | |
| 269 | + "--raw-field", | |
| 270 | + "name=t3code", | |
| 271 | + "--raw-field", | |
| 272 | + "visibility=public", | |
| 273 | + "--raw-field", | |
| 274 | + "namespace_id=1234", | |
| 275 | + ], | |
| 276 | + }), | |
| 277 | + ); | |
| 278 | + }), | |
| 279 | + ); | |
| 280 | + | |
| 220 | 281 | it.effect("does not pass unsupported force flags when checking out merge requests", () => |
| 221 | 282 | Effect.gen(function* () { |
| 222 | 283 | mockedRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, type DateTime } from "effect"; |
| 2 | 2 | |
| 3 | −import { TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 3 | +import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; | |
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | 6 | decodeGitLabMergeRequestJson, |
| @@ -66,6 +66,12 @@ export interface GitLabCliShape { · 59 unmodified lines | ||
| 66 | 66 | readonly repository: string; |
| 67 | 67 | }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>; |
| 68 | 68 | |
| 69 | + readonly createRepository: (input: { | |
| 70 | + readonly cwd: string; | |
| 71 | + readonly repository: string; | |
| 72 | + readonly visibility: SourceControlRepositoryVisibility; | |
| 73 | + }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>; | |
| 74 | + | |
| 69 | 75 | readonly createMergeRequest: (input: { |
| 70 | 76 | readonly cwd: string; |
| 71 | 77 | readonly baseBranch: string; |
| @@ -161,12 +167,16 @@ const RawGitLabDefaultBranchSchema = Schema.Struct({ · 89 unmodified lines | ||
| 161 | 167 | default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 162 | 168 | }); |
| 163 | 169 | |
| 170 | +const RawGitLabNamespaceSchema = Schema.Struct({ | |
| 171 | + id: Schema.Number, | |
| 172 | +}); | |
| 173 | + | |
| 164 | 174 | function normalizeRepositoryCloneUrls( |
| 165 | 175 | raw: Schema.Schema.Type<typeof RawGitLabRepositoryCloneUrlsSchema>, |
| 166 | 176 | ): GitLabRepositoryCloneUrls { |
| 167 | 177 | return { |
| 168 | 178 | nameWithOwner: raw.path_with_namespace, |
| 169 | − url: raw.http_url_to_repo || raw.web_url, | |
| 179 | + url: raw.web_url, | |
| 170 | 180 | sshUrl: raw.ssh_url_to_repo, |
| 171 | 181 | }; |
| 172 | 182 | } |
| @@ -174,7 +184,7 @@ function normalizeRepositoryCloneUrls( · 1 unmodified line | ||
| 174 | 184 | function decodeGitLabJson<S extends Schema.Top>( |
| 175 | 185 | raw: string, |
| 176 | 186 | schema: S, |
| 177 | − operation: "getRepositoryCloneUrls" | "getDefaultBranch", | |
| 187 | + operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", | |
| 178 | 188 | invalidDetail: string, |
| 179 | 189 | ): Effect.Effect<S["Type"], GitLabCliError, S["DecodingServices"]> { |
| 180 | 190 | return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( |
| @@ -228,6 +238,19 @@ function toSummaryWithOptionalUpdatedAt( · 47 unmodified lines | ||
| 228 | 238 | return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary; |
| 229 | 239 | } |
| 230 | 240 | |
| 241 | +function parseRepositoryPath(repository: string): { | |
| 242 | + readonly namespacePath: string | null; | |
| 243 | + readonly projectPath: string; | |
| 244 | +} { | |
| 245 | + const parts = repository | |
| 246 | + .split("/") | |
| 247 | + .map((part) => part.trim()) | |
| 248 | + .filter((part) => part.length > 0); | |
| 249 | + const projectPath = parts.at(-1) ?? repository.trim(); | |
| 250 | + const namespacePath = parts.length > 1 ? parts.slice(0, -1).join("/") : null; | |
| 251 | + return { namespacePath, projectPath }; | |
| 252 | +} | |
| 253 | + | |
| 231 | 254 | export const make = Effect.fn("makeGitLabCli")(function* () { |
| 232 | 255 | const process = yield* VcsProcess; |
| 233 | 256 | |
| @@ -320,6 +343,59 @@ export const make = Effect.fn("makeGitLabCli")(function* () { · 86 unmodified lines | ||
| 320 | 343 | ), |
| 321 | 344 | Effect.map(normalizeRepositoryCloneUrls), |
| 322 | 345 | ), |
| 346 | + createRepository: (input) => { | |
| 347 | + const { namespacePath, projectPath } = parseRepositoryPath(input.repository); | |
| 348 | + const namespaceId: Effect.Effect<number | null, GitLabCliError> = namespacePath | |
| 349 | + ? execute({ | |
| 350 | + cwd: input.cwd, | |
| 351 | + args: ["api", `namespaces/${encodeURIComponent(namespacePath)}`], | |
| 352 | + }).pipe( | |
| 353 | + Effect.map((result) => result.stdout.trim()), | |
| 354 | + Effect.flatMap((raw) => | |
| 355 | + decodeGitLabJson( | |
| 356 | + raw, | |
| 357 | + RawGitLabNamespaceSchema, | |
| 358 | + "createRepository", | |
| 359 | + "GitLab CLI returned invalid namespace JSON.", | |
| 360 | + ), | |
| 361 | + ), | |
| 362 | + Effect.map((namespace) => namespace.id), | |
| 363 | + ) | |
| 364 | + : Effect.succeed(null); | |
| 365 | + | |
| 366 | + return namespaceId.pipe( | |
| 367 | + Effect.flatMap((resolvedNamespaceId) => | |
| 368 | + execute({ | |
| 369 | + cwd: input.cwd, | |
| 370 | + args: [ | |
| 371 | + "api", | |
| 372 | + "--method", | |
| 373 | + "POST", | |
| 374 | + "projects", | |
| 375 | + "--raw-field", | |
| 376 | + `path=${projectPath}`, | |
| 377 | + "--raw-field", | |
| 378 | + `name=${projectPath}`, | |
| 379 | + "--raw-field", | |
| 380 | + `visibility=${input.visibility}`, | |
| 381 | + ...(resolvedNamespaceId === null | |
| 382 | + ? [] | |
| 383 | + : ["--raw-field", `namespace_id=${resolvedNamespaceId}`]), | |
| 384 | + ], | |
| 385 | + }), | |
| 386 | + ), | |
| 387 | + Effect.map((result) => result.stdout.trim()), | |
| 388 | + Effect.flatMap((raw) => | |
| 389 | + decodeGitLabJson( | |
| 390 | + raw, | |
| 391 | + RawGitLabRepositoryCloneUrlsSchema, | |
| 392 | + "createRepository", | |
| 393 | + "GitLab CLI returned invalid repository JSON.", | |
| 394 | + ), | |
| 395 | + ), | |
| 396 | + Effect.map(normalizeRepositoryCloneUrls), | |
| 397 | + ); | |
| 398 | + }, | |
| 323 | 399 | createMergeRequest: (input) => { |
| 324 | 400 | const sourceProject = sourceProjectIdentifier(input.source); |
| 325 | 401 | return execute({ |
| @@ -92,6 +92,10 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { · 91 unmodified lines | ||
| 92 | 92 | gitlab |
| 93 | 93 | .getRepositoryCloneUrls(input) |
| 94 | 94 | .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), |
| 95 | + createRepository: (input) => | |
| 96 | + gitlab | |
| 97 | + .createRepository(input) | |
| 98 | + .pipe(Effect.mapError((error) => providerError("createRepository", error))), | |
| 95 | 99 | getDefaultBranch: (input) => |
| 96 | 100 | gitlab |
| 97 | 101 | .getDefaultBranch(input) |
| @@ -115,6 +115,9 @@ Logged in to github.com account juliusmarminge (keyring) · 114 unmodified lines | ||
| 115 | 115 | }, |
| 116 | 116 | ], |
| 117 | 117 | ); |
| 118 | + const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); | |
| 119 | + assert.ok(bitbucket); | |
| 120 | + assert.strictEqual("executable" in bitbucket, false); | |
| 118 | 121 | }).pipe(Effect.provide(testLayer)); |
| 119 | 122 | }); |
| 120 | 123 | |
| @@ -204,9 +207,9 @@ Logged in as bitbucket-user · 83 unmodified lines | ||
| 204 | 207 | }, |
| 205 | 208 | { |
| 206 | 209 | kind: "bitbucket", |
| 207 | − auth: "authenticated", | |
| 208 | − account: Option.some("bitbucket-user"), | |
| 209 | − detail: Option.none(), | |
| 210 | + auth: "unknown", | |
| 211 | + account: Option.none(), | |
| 212 | + detail: Option.some("Bitbucket provider support is not available yet."), | |
| 210 | 213 | }, |
| 211 | 214 | ], |
| 212 | 215 | ); |
| @@ -13,20 +13,22 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; · 12 unmodified lines | ||
| 13 | 13 | |
| 14 | 14 | interface DiscoveryProbe { |
| 15 | 15 | readonly label: string; |
| 16 | − readonly executable: string; | |
| 17 | − readonly versionArgs: ReadonlyArray<string>; | |
| 16 | + readonly executable?: string; | |
| 17 | + readonly versionArgs?: ReadonlyArray<string>; | |
| 18 | 18 | readonly implemented: boolean; |
| 19 | 19 | readonly installHint: string; |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | 22 | type VcsProbe = DiscoveryProbe & { |
| 23 | 23 | readonly kind: VcsDriverKind; |
| 24 | + readonly executable: string; | |
| 25 | + readonly versionArgs: ReadonlyArray<string>; | |
| 24 | 26 | }; |
| 25 | 27 | |
| 26 | 28 | type ProviderProbe = DiscoveryProbe & { |
| 27 | 29 | readonly kind: SourceControlProviderKind; |
| 28 | − readonly authArgs: ReadonlyArray<string>; | |
| 29 | − readonly parseAuth: (input: AuthProbeInput) => SourceControlProviderAuth; | |
| 30 | + readonly authArgs?: ReadonlyArray<string>; | |
| 31 | + readonly parseAuth?: (input: AuthProbeInput) => SourceControlProviderAuth; | |
| 30 | 32 | }; |
| 31 | 33 | |
| 32 | 34 | interface AuthProbeInput { |
| @@ -38,7 +40,7 @@ interface AuthProbeInput { · 5 unmodified lines | ||
| 38 | 40 | interface DiscoveryProbeResult<Kind extends string> { |
| 39 | 41 | readonly kind: Kind; |
| 40 | 42 | readonly label: string; |
| 41 | − readonly executable: string; | |
| 43 | + readonly executable?: string; | |
| 42 | 44 | readonly implemented: boolean; |
| 43 | 45 | readonly status: "available" | "missing"; |
| 44 | 46 | readonly version: Option.Option<string>; |
| @@ -101,12 +103,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ · 56 unmodified lines | ||
| 101 | 103 | { |
| 102 | 104 | kind: "bitbucket", |
| 103 | 105 | label: "Bitbucket", |
| 104 | − executable: "bb", | |
| 105 | − versionArgs: ["--version"], | |
| 106 | − authArgs: ["auth", "status"], | |
| 107 | − parseAuth: parseBitbucketAuth, | |
| 108 | 106 | implemented: false, |
| 109 | − installHint: "Install a Bitbucket CLI (`bb`) and authenticate it for your Bitbucket workspace.", | |
| 107 | + installHint: "Bitbucket provider support is not available yet.", | |
| 110 | 108 | }, |
| 111 | 109 | ]; |
| 112 | 110 | |
| @@ -266,37 +264,6 @@ function parseAzureAuth(input: AuthProbeInput): SourceControlProviderAuth { · 153 unmodified lines | ||
| 266 | 264 | }); |
| 267 | 265 | } |
| 268 | 266 | |
| 269 | −function parseBitbucketAuth(input: AuthProbeInput): SourceControlProviderAuth { | |
| 270 | − const output = combinedAuthOutput(input); | |
| 271 | − const account = matchFirst(output, [ | |
| 272 | − /Logged in to .* as\s+([^\s(]+)/iu, | |
| 273 | − /Logged in as\s+([^\s(]+)/iu, | |
| 274 | − /account:\s*([^\s(]+)/iu, | |
| 275 | − /user:\s*([^\s(]+)/iu, | |
| 276 | − /username:\s*([^\s(]+)/iu, | |
| 277 | − ]); | |
| 278 | − const host = parseCliHost(output) ?? "bitbucket.org"; | |
| 279 | − | |
| 280 | − if (input.exitCode !== 0) { | |
| 281 | − return providerAuth({ | |
| 282 | − status: "unauthenticated", | |
| 283 | − host, | |
| 284 | − detail: | |
| 285 | − firstSafeAuthLine(output) ?? "Authenticate the Bitbucket CLI before enabling Bitbucket.", | |
| 286 | − }); | |
| 287 | − } | |
| 288 | − | |
| 289 | − if (account) { | |
| 290 | − return providerAuth({ status: "authenticated", account, host }); | |
| 291 | − } | |
| 292 | − | |
| 293 | − return providerAuth({ | |
| 294 | − status: "unknown", | |
| 295 | − host, | |
| 296 | − detail: firstSafeAuthLine(output) ?? "Bitbucket CLI auth status could not be parsed.", | |
| 297 | − }); | |
| 298 | −} | |
| 299 | − | |
| 300 | 267 | export interface SourceControlDiscoveryShape { |
| 301 | 268 | readonly discover: Effect.Effect<SourceControlDiscoveryResult>; |
| 302 | 269 | } |
| @@ -314,12 +281,27 @@ export const layer = Layer.effect( · 11 unmodified lines | ||
| 314 | 281 | |
| 315 | 282 | const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>( |
| 316 | 283 | input: DiscoveryProbe & { readonly kind: Kind }, |
| 317 | − ): Effect.Effect<DiscoveryProbeResult<Kind>> => | |
| 318 | − process | |
| 284 | + ): Effect.Effect<DiscoveryProbeResult<Kind>> => { | |
| 285 | + const executable = input.executable; | |
| 286 | + const versionArgs = input.versionArgs; | |
| 287 | + | |
| 288 | + if (!executable || !versionArgs) { | |
| 289 | + return Effect.succeed({ | |
| 290 | + kind: input.kind, | |
| 291 | + label: input.label, | |
| 292 | + implemented: input.implemented, | |
| 293 | + status: "missing" as const, | |
| 294 | + version: Option.none<string>(), | |
| 295 | + installHint: input.installHint, | |
| 296 | + detail: Option.some(input.installHint), | |
| 297 | + } satisfies DiscoveryProbeResult<Kind>); | |
| 298 | + } | |
| 299 | + | |
| 300 | + return process | |
| 319 | 301 | .run({ |
| 320 | 302 | operation: "source-control.discovery.probe", |
| 321 | − command: input.executable, | |
| 322 | − args: input.versionArgs, | |
| 303 | + command: executable, | |
| 304 | + args: versionArgs, | |
| 323 | 305 | cwd: config.cwd, |
| 324 | 306 | timeoutMs: 5_000, |
| 325 | 307 | maxOutputBytes: 8_000, |
| @@ -331,7 +313,7 @@ export const layer = Layer.effect( · 5 unmodified lines | ||
| 331 | 313 | ({ |
| 332 | 314 | kind: input.kind, |
| 333 | 315 | label: input.label, |
| 334 | − executable: input.executable, | |
| 316 | + executable, | |
| 335 | 317 | implemented: input.implemented, |
| 336 | 318 | status: "available" as const, |
| 337 | 319 | version: Option.orElse(firstNonEmptyLine(result.stdout), () => |
| @@ -345,7 +327,7 @@ export const layer = Layer.effect( · 7 unmodified lines | ||
| 345 | 327 | Effect.succeed({ |
| 346 | 328 | kind: input.kind, |
| 347 | 329 | label: input.label, |
| 348 | − executable: input.executable, | |
| 330 | + executable, | |
| 349 | 331 | implemented: input.implemented, |
| 350 | 332 | status: "missing" as const, |
| 351 | 333 | version: Option.none<string>(), |
| @@ -354,10 +336,22 @@ export const layer = Layer.effect( · 2 unmodified lines | ||
| 354 | 336 | } satisfies DiscoveryProbeResult<Kind>), |
| 355 | 337 | ), |
| 356 | 338 | ); |
| 339 | + }; | |
| 357 | 340 | |
| 358 | 341 | const probeProvider = (input: ProviderProbe) => |
| 359 | 342 | probe(input).pipe( |
| 360 | 343 | Effect.flatMap((item) => { |
| 344 | + const executable = input.executable; | |
| 345 | + const authArgs = input.authArgs; | |
| 346 | + const parseAuth = input.parseAuth; | |
| 347 | + | |
| 348 | + if (!executable || !authArgs || !parseAuth) { | |
| 349 | + return Effect.succeed({ | |
| 350 | + ...item, | |
| 351 | + auth: unknownAuth(input.installHint), | |
| 352 | + } satisfies SourceControlProviderDiscoveryItem); | |
| 353 | + } | |
| 354 | + | |
| 361 | 355 | if (item.status !== "available") { |
| 362 | 356 | return Effect.succeed({ |
| 363 | 357 | ...item, |
| @@ -368,8 +362,8 @@ export const layer = Layer.effect( · 4 unmodified lines | ||
| 368 | 362 | return process |
| 369 | 363 | .run({ |
| 370 | 364 | operation: "source-control.discovery.auth", |
| 371 | − command: input.executable, | |
| 372 | − args: input.authArgs, | |
| 365 | + command: executable, | |
| 366 | + args: authArgs, | |
| 373 | 367 | cwd: config.cwd, |
| 374 | 368 | allowNonZeroExit: true, |
| 375 | 369 | timeoutMs: 5_000, |
| @@ -381,7 +375,7 @@ export const layer = Layer.effect( · 5 unmodified lines | ||
| 381 | 375 | (result) => |
| 382 | 376 | ({ |
| 383 | 377 | ...item, |
| 384 | − auth: input.parseAuth(result), | |
| 378 | + auth: parseAuth(result), | |
| 385 | 379 | }) satisfies SourceControlProviderDiscoveryItem, |
| 386 | 380 | ), |
| 387 | 381 | Effect.catch((cause) => |
| @@ -6,6 +6,7 @@ import type { · 5 unmodified lines | ||
| 6 | 6 | SourceControlProviderInfo, |
| 7 | 7 | SourceControlProviderKind, |
| 8 | 8 | SourceControlRepositoryCloneUrls, |
| 9 | + SourceControlRepositoryVisibility, | |
| 9 | 10 | } from "@t3tools/contracts"; |
| 10 | 11 | |
| 11 | 12 | export interface SourceControlProviderContext { |
| @@ -50,6 +51,11 @@ export interface SourceControlProviderShape { · 38 unmodified lines | ||
| 50 | 51 | readonly context?: SourceControlProviderContext; |
| 51 | 52 | readonly repository: string; |
| 52 | 53 | }) => Effect.Effect<SourceControlRepositoryCloneUrls, SourceControlProviderError>; |
| 54 | + readonly createRepository: (input: { | |
| 55 | + readonly cwd: string; | |
| 56 | + readonly repository: string; | |
| 57 | + readonly visibility: SourceControlRepositoryVisibility; | |
| 58 | + }) => Effect.Effect<SourceControlRepositoryCloneUrls, SourceControlProviderError>; | |
| 53 | 59 | readonly getDefaultBranch: (input: { |
| 54 | 60 | readonly cwd: string; |
| 55 | 61 | readonly context?: SourceControlProviderContext; |
| @@ -58,6 +58,7 @@ function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProv · 57 unmodified lines | ||
| 58 | 58 | getChangeRequest: () => unsupported("getChangeRequest"), |
| 59 | 59 | createChangeRequest: () => unsupported("createChangeRequest"), |
| 60 | 60 | getRepositoryCloneUrls: () => unsupported("getRepositoryCloneUrls"), |
| 61 | + createRepository: () => unsupported("createRepository"), | |
| 61 | 62 | getDefaultBranch: () => unsupported("getDefaultBranch"), |
| 62 | 63 | checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), |
| 63 | 64 | }); |
| @@ -0,0 +1,322 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { assert, it } from "@effect/vitest"; | |
| 3 | +import { Effect, FileSystem, Layer } from "effect"; | |
| 4 | +import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 5 | + | |
| 6 | +import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts"; | |
| 7 | + | |
| 8 | +import { ServerConfig } from "../config.ts"; | |
| 9 | +import { | |
| 10 | + GitVcsDriver, | |
| 11 | + type ExecuteGitResult, | |
| 12 | + type GitVcsDriverShape, | |
| 13 | +} from "../vcs/GitVcsDriver.ts"; | |
| 14 | +import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; | |
| 15 | +import type { SourceControlProviderShape } from "./SourceControlProvider.ts"; | |
| 16 | +import { SourceControlRepositoryService, layer } from "./SourceControlRepositoryService.ts"; | |
| 17 | + | |
| 18 | +const CLONE_URLS = { | |
| 19 | + nameWithOwner: "octocat/t3code", | |
| 20 | + url: "https://github.com/octocat/t3code", | |
| 21 | + sshUrl: "git@github.com:octocat/t3code.git", | |
| 22 | +}; | |
| 23 | + | |
| 24 | +function makeProvider( | |
| 25 | + overrides: Partial<SourceControlProviderShape> = {}, | |
| 26 | +): SourceControlProviderShape { | |
| 27 | + const unsupported = (operation: string) => | |
| 28 | + Effect.die(`unexpected provider operation ${operation}`) as Effect.Effect< | |
| 29 | + never, | |
| 30 | + SourceControlProviderError | |
| 31 | + >; | |
| 32 | + | |
| 33 | + return { | |
| 34 | + kind: "github", | |
| 35 | + listChangeRequests: () => unsupported("listChangeRequests"), | |
| 36 | + getChangeRequest: () => unsupported("getChangeRequest"), | |
| 37 | + createChangeRequest: () => unsupported("createChangeRequest"), | |
| 38 | + getRepositoryCloneUrls: () => Effect.succeed(CLONE_URLS), | |
| 39 | + createRepository: () => Effect.succeed(CLONE_URLS), | |
| 40 | + getDefaultBranch: () => Effect.succeed(null), | |
| 41 | + checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), | |
| 42 | + ...overrides, | |
| 43 | + }; | |
| 44 | +} | |
| 45 | + | |
| 46 | +function processOutput(): ExecuteGitResult { | |
| 47 | + return { | |
| 48 | + exitCode: ChildProcessSpawner.ExitCode(0), | |
| 49 | + stdout: "", | |
| 50 | + stderr: "", | |
| 51 | + stdoutTruncated: false, | |
| 52 | + stderrTruncated: false, | |
| 53 | + }; | |
| 54 | +} | |
| 55 | + | |
| 56 | +function makeLayer(input: { | |
| 57 | + readonly provider?: SourceControlProviderShape; | |
| 58 | + readonly git?: Partial<GitVcsDriverShape>; | |
| 59 | +}) { | |
| 60 | + return layer.pipe( | |
| 61 | + Layer.provide( | |
| 62 | + Layer.mock(SourceControlProviderRegistry)({ | |
| 63 | + get: () => Effect.succeed(input.provider ?? makeProvider()), | |
| 64 | + }), | |
| 65 | + ), | |
| 66 | + Layer.provide( | |
| 67 | + Layer.mock(GitVcsDriver)({ | |
| 68 | + execute: () => Effect.succeed(processOutput()), | |
| 69 | + ensureRemote: () => Effect.succeed("origin"), | |
| 70 | + pushCurrentBranch: () => | |
| 71 | + Effect.succeed({ | |
| 72 | + status: "pushed" as const, | |
| 73 | + branch: "feature/remote-v1", | |
| 74 | + upstreamBranch: "origin/feature/remote-v1", | |
| 75 | + setUpstream: true, | |
| 76 | + }), | |
| 77 | + ...input.git, | |
| 78 | + }), | |
| 79 | + ), | |
| 80 | + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-repos-" })), | |
| 81 | + Layer.provideMerge(NodeServices.layer), | |
| 82 | + ); | |
| 83 | +} | |
| 84 | + | |
| 85 | +it.effect("looks up repositories through the requested provider without search", () => { | |
| 86 | + const calls: Array<{ cwd: string; repository: string }> = []; | |
| 87 | + const provider = makeProvider({ | |
| 88 | + getRepositoryCloneUrls: (input) => | |
| 89 | + Effect.sync(() => { | |
| 90 | + calls.push({ cwd: input.cwd, repository: input.repository }); | |
| 91 | + return CLONE_URLS; | |
| 92 | + }), | |
| 93 | + }); | |
| 94 | + | |
| 95 | + return Effect.gen(function* () { | |
| 96 | + const service = yield* SourceControlRepositoryService; | |
| 97 | + const result = yield* service.lookupRepository({ | |
| 98 | + provider: "github", | |
| 99 | + repository: "octocat/t3code", | |
| 100 | + cwd: "/workspace", | |
| 101 | + }); | |
| 102 | + | |
| 103 | + assert.deepStrictEqual(result, { provider: "github", ...CLONE_URLS }); | |
| 104 | + assert.deepStrictEqual(calls, [{ cwd: "/workspace", repository: "octocat/t3code" }]); | |
| 105 | + }).pipe(Effect.provide(makeLayer({ provider }))); | |
| 106 | +}); | |
| 107 | + | |
| 108 | +it.effect("clones a looked-up repository into the requested destination", () => | |
| 109 | + Effect.gen(function* () { | |
| 110 | + const fs = yield* FileSystem.FileSystem; | |
| 111 | + const parent = yield* fs.makeTempDirectoryScoped({ | |
| 112 | + prefix: "t3-source-control-clone-parent-", | |
| 113 | + }); | |
| 114 | + const destinationPath = `${parent}/t3code`; | |
| 115 | + const cloneCalls: Array<{ cwd: string; args: ReadonlyArray<string> }> = []; | |
| 116 | + | |
| 117 | + yield* Effect.gen(function* () { | |
| 118 | + const service = yield* SourceControlRepositoryService; | |
| 119 | + const result = yield* service.cloneRepository({ | |
| 120 | + provider: "github", | |
| 121 | + repository: "octocat/t3code", | |
| 122 | + destinationPath, | |
| 123 | + protocol: "https", | |
| 124 | + }); | |
| 125 | + | |
| 126 | + assert.deepStrictEqual(result, { | |
| 127 | + cwd: destinationPath, | |
| 128 | + remoteUrl: CLONE_URLS.url, | |
| 129 | + repository: { provider: "github", ...CLONE_URLS }, | |
| 130 | + }); | |
| 131 | + assert.deepStrictEqual(cloneCalls, [ | |
| 132 | + { | |
| 133 | + cwd: parent, | |
| 134 | + args: ["clone", CLONE_URLS.url, "t3code"], | |
| 135 | + }, | |
| 136 | + ]); | |
| 137 | + }).pipe( | |
| 138 | + Effect.provide( | |
| 139 | + makeLayer({ | |
| 140 | + git: { | |
| 141 | + execute: (input) => | |
| 142 | + Effect.sync(() => { | |
| 143 | + cloneCalls.push({ cwd: input.cwd, args: input.args }); | |
| 144 | + return processOutput(); | |
| 145 | + }), | |
| 146 | + }, | |
| 147 | + }), | |
| 148 | + ), | |
| 149 | + ); | |
| 150 | + }).pipe(Effect.provide(NodeServices.layer)), | |
| 151 | +); | |
| 152 | + | |
| 153 | +it.effect("publishes by creating the repository, adding a remote, and pushing upstream", () => { | |
| 154 | + const createCalls: Array<{ cwd: string; repository: string; visibility: string }> = []; | |
| 155 | + const remoteCalls: Array<{ cwd: string; preferredName: string; url: string }> = []; | |
| 156 | + const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = []; | |
| 157 | + const provider = makeProvider({ | |
| 158 | + createRepository: (input) => | |
| 159 | + Effect.sync(() => { | |
| 160 | + createCalls.push({ | |
| 161 | + cwd: input.cwd, | |
| 162 | + repository: input.repository, | |
| 163 | + visibility: input.visibility, | |
| 164 | + }); | |
| 165 | + return CLONE_URLS; | |
| 166 | + }), | |
| 167 | + }); | |
| 168 | + | |
| 169 | + return Effect.gen(function* () { | |
| 170 | + const service = yield* SourceControlRepositoryService; | |
| 171 | + const result = yield* service.publishRepository({ | |
| 172 | + cwd: "/workspace", | |
| 173 | + provider: "github", | |
| 174 | + repository: "octocat/t3code", | |
| 175 | + visibility: "private", | |
| 176 | + remoteName: "origin", | |
| 177 | + protocol: "ssh", | |
| 178 | + }); | |
| 179 | + | |
| 180 | + assert.deepStrictEqual(result, { | |
| 181 | + repository: { provider: "github", ...CLONE_URLS }, | |
| 182 | + remoteName: "origin", | |
| 183 | + remoteUrl: CLONE_URLS.sshUrl, | |
| 184 | + branch: "feature/remote-v1", | |
| 185 | + upstreamBranch: "origin/feature/remote-v1", | |
| 186 | + status: "pushed", | |
| 187 | + }); | |
| 188 | + assert.deepStrictEqual(createCalls, [ | |
| 189 | + { cwd: "/workspace", repository: "octocat/t3code", visibility: "private" }, | |
| 190 | + ]); | |
| 191 | + assert.deepStrictEqual(remoteCalls, [ | |
| 192 | + { cwd: "/workspace", preferredName: "origin", url: CLONE_URLS.sshUrl }, | |
| 193 | + ]); | |
| 194 | + assert.deepStrictEqual(pushCalls, [{ cwd: "/workspace", remoteName: "origin" }]); | |
| 195 | + }).pipe( | |
| 196 | + Effect.provide( | |
| 197 | + makeLayer({ | |
| 198 | + provider, | |
| 199 | + git: { | |
| 200 | + ensureRemote: (input) => | |
| 201 | + Effect.sync(() => { | |
| 202 | + remoteCalls.push(input); | |
| 203 | + return "origin"; | |
| 204 | + }), | |
| 205 | + pushCurrentBranch: (cwd, _fallbackBranch, options) => | |
| 206 | + Effect.sync(() => { | |
| 207 | + pushCalls.push({ cwd, remoteName: options?.remoteName }); | |
| 208 | + return { | |
| 209 | + status: "pushed" as const, | |
| 210 | + branch: "feature/remote-v1", | |
| 211 | + upstreamBranch: "origin/feature/remote-v1", | |
| 212 | + setUpstream: true, | |
| 213 | + }; | |
| 214 | + }), | |
| 215 | + }, | |
| 216 | + }), | |
| 217 | + ), | |
| 218 | + ); | |
| 219 | +}); | |
| 220 | + | |
| 221 | +it.effect("publishes to the remote name returned by ensureRemote", () => { | |
| 222 | + const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = []; | |
| 223 | + | |
| 224 | + return Effect.gen(function* () { | |
| 225 | + const service = yield* SourceControlRepositoryService; | |
| 226 | + const result = yield* service.publishRepository({ | |
| 227 | + cwd: "/workspace", | |
| 228 | + provider: "github", | |
| 229 | + repository: "octocat/t3code", | |
| 230 | + visibility: "private", | |
| 231 | + remoteName: "origin", | |
| 232 | + protocol: "ssh", | |
| 233 | + }); | |
| 234 | + | |
| 235 | + assert.equal(result.remoteName, "origin-1"); | |
| 236 | + assert.deepStrictEqual(pushCalls, [{ cwd: "/workspace", remoteName: "origin-1" }]); | |
| 237 | + }).pipe( | |
| 238 | + Effect.provide( | |
| 239 | + makeLayer({ | |
| 240 | + git: { | |
| 241 | + ensureRemote: () => Effect.succeed("origin-1"), | |
| 242 | + pushCurrentBranch: (cwd, _fallbackBranch, options) => | |
| 243 | + Effect.sync(() => { | |
| 244 | + pushCalls.push({ cwd, remoteName: options?.remoteName }); | |
| 245 | + return { | |
| 246 | + status: "pushed" as const, | |
| 247 | + branch: "feature/remote-v1", | |
| 248 | + upstreamBranch: `${options?.remoteName ?? "missing"}/feature/remote-v1`, | |
| 249 | + setUpstream: true, | |
| 250 | + }; | |
| 251 | + }), | |
| 252 | + }, | |
| 253 | + }), | |
| 254 | + ), | |
| 255 | + ); | |
| 256 | +}); | |
| 257 | + | |
| 258 | +it.effect("publish succeeds with status remote_added when the local repo has no commits", () => { | |
| 259 | + let pushCalls = 0; | |
| 260 | + return Effect.gen(function* () { | |
| 261 | + const service = yield* SourceControlRepositoryService; | |
| 262 | + const result = yield* service.publishRepository({ | |
| 263 | + cwd: "/workspace", | |
| 264 | + provider: "github", | |
| 265 | + repository: "octocat/t3code", | |
| 266 | + visibility: "private", | |
| 267 | + remoteName: "origin", | |
| 268 | + protocol: "ssh", | |
| 269 | + }); | |
| 270 | + | |
| 271 | + assert.deepStrictEqual(result, { | |
| 272 | + repository: { provider: "github", ...CLONE_URLS }, | |
| 273 | + remoteName: "origin", | |
| 274 | + remoteUrl: CLONE_URLS.sshUrl, | |
| 275 | + branch: "main", | |
| 276 | + status: "remote_added", | |
| 277 | + }); | |
| 278 | + assert.strictEqual(pushCalls, 0); | |
| 279 | + }).pipe( | |
| 280 | + Effect.provide( | |
| 281 | + makeLayer({ | |
| 282 | + git: { | |
| 283 | + execute: (input) => | |
| 284 | + input.args[0] === "rev-parse" | |
| 285 | + ? Effect.fail( | |
| 286 | + new GitCommandError({ | |
| 287 | + operation: input.operation, | |
| 288 | + command: "git rev-parse --verify HEAD", | |
| 289 | + cwd: input.cwd, | |
| 290 | + detail: "fatal: Needed a single revision", | |
| 291 | + }), | |
| 292 | + ) | |
| 293 | + : Effect.succeed(processOutput()), | |
| 294 | + statusDetails: () => | |
| 295 | + Effect.succeed({ | |
| 296 | + isRepo: true, | |
| 297 | + hasOriginRemote: true, | |
| 298 | + isDefaultBranch: true, | |
| 299 | + branch: "main", | |
| 300 | + upstreamRef: null, | |
| 301 | + hasWorkingTreeChanges: false, | |
| 302 | + workingTree: { files: [], insertions: 0, deletions: 0 }, | |
| 303 | + hasUpstream: false, | |
| 304 | + aheadCount: 0, | |
| 305 | + behindCount: 0, | |
| 306 | + aheadOfDefaultCount: 0, | |
| 307 | + }), | |
| 308 | + pushCurrentBranch: () => | |
| 309 | + Effect.sync(() => { | |
| 310 | + pushCalls += 1; | |
| 311 | + return { | |
| 312 | + status: "pushed" as const, | |
| 313 | + branch: "main", | |
| 314 | + upstreamBranch: "origin/main", | |
| 315 | + setUpstream: true, | |
| 316 | + }; | |
| 317 | + }), | |
| 318 | + }, | |
| 319 | + }), | |
| 320 | + ), | |
| 321 | + ); | |
| 322 | +}); | |
| @@ -0,0 +1,320 @@ | ||
| 1 | +import OS from "node:os"; | |
| 2 | +import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + SourceControlRepositoryError, | |
| 6 | + type SourceControlCloneRepositoryInput, | |
| 7 | + type SourceControlCloneRepositoryResult, | |
| 8 | + type SourceControlCloneProtocol, | |
| 9 | + type SourceControlProviderKind, | |
| 10 | + type SourceControlPublishRepositoryInput, | |
| 11 | + type SourceControlPublishRepositoryResult, | |
| 12 | + type SourceControlRepositoryCloneUrls, | |
| 13 | + type SourceControlRepositoryInfo, | |
| 14 | + type SourceControlRepositoryLookupInput, | |
| 15 | +} from "@t3tools/contracts"; | |
| 16 | + | |
| 17 | +import { ServerConfig } from "../config.ts"; | |
| 18 | +import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; | |
| 19 | +import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; | |
| 20 | + | |
| 21 | +export interface SourceControlRepositoryServiceShape { | |
| 22 | + readonly lookupRepository: ( | |
| 23 | + input: SourceControlRepositoryLookupInput, | |
| 24 | + ) => Effect.Effect<SourceControlRepositoryInfo, SourceControlRepositoryError>; | |
| 25 | + readonly cloneRepository: ( | |
| 26 | + input: SourceControlCloneRepositoryInput, | |
| 27 | + ) => Effect.Effect<SourceControlCloneRepositoryResult, SourceControlRepositoryError>; | |
| 28 | + readonly publishRepository: ( | |
| 29 | + input: SourceControlPublishRepositoryInput, | |
| 30 | + ) => Effect.Effect<SourceControlPublishRepositoryResult, SourceControlRepositoryError>; | |
| 31 | +} | |
| 32 | + | |
| 33 | +export class SourceControlRepositoryService extends Context.Service< | |
| 34 | + SourceControlRepositoryService, | |
| 35 | + SourceControlRepositoryServiceShape | |
| 36 | +>()("t3/source-control/SourceControlRepositoryService") {} | |
| 37 | + | |
| 38 | +function detailFromUnknown(cause: unknown): string { | |
| 39 | + if (typeof cause === "object" && cause !== null) { | |
| 40 | + if ("detail" in cause && typeof cause.detail === "string" && cause.detail.length > 0) { | |
| 41 | + return cause.detail; | |
| 42 | + } | |
| 43 | + if ("message" in cause && typeof cause.message === "string" && cause.message.length > 0) { | |
| 44 | + return cause.message; | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + return "An unexpected source control error occurred."; | |
| 49 | +} | |
| 50 | + | |
| 51 | +function repositoryError(input: { | |
| 52 | + readonly operation: string; | |
| 53 | + readonly provider: SourceControlProviderKind; | |
| 54 | + readonly detail: string; | |
| 55 | + readonly cause?: unknown; | |
| 56 | +}): SourceControlRepositoryError { | |
| 57 | + return new SourceControlRepositoryError({ | |
| 58 | + provider: input.provider, | |
| 59 | + operation: input.operation, | |
| 60 | + detail: input.detail, | |
| 61 | + ...(input.cause === undefined ? {} : { cause: input.cause }), | |
| 62 | + }); | |
| 63 | +} | |
| 64 | + | |
| 65 | +function mapRepositoryError(operation: string, provider: SourceControlProviderKind) { | |
| 66 | + return Effect.mapError((cause: unknown) => | |
| 67 | + Schema.is(SourceControlRepositoryError)(cause) | |
| 68 | + ? cause | |
| 69 | + : repositoryError({ | |
| 70 | + operation, | |
| 71 | + provider, | |
| 72 | + detail: detailFromUnknown(cause), | |
| 73 | + cause, | |
| 74 | + }), | |
| 75 | + ); | |
| 76 | +} | |
| 77 | + | |
| 78 | +function toRepositoryInfo( | |
| 79 | + provider: SourceControlProviderKind, | |
| 80 | + urls: SourceControlRepositoryCloneUrls, | |
| 81 | +): SourceControlRepositoryInfo { | |
| 82 | + return { | |
| 83 | + provider, | |
| 84 | + nameWithOwner: urls.nameWithOwner, | |
| 85 | + url: urls.url, | |
| 86 | + sshUrl: urls.sshUrl, | |
| 87 | + }; | |
| 88 | +} | |
| 89 | + | |
| 90 | +function selectRemoteUrl( | |
| 91 | + urls: SourceControlRepositoryCloneUrls, | |
| 92 | + protocol: SourceControlCloneProtocol | undefined, | |
| 93 | +): string { | |
| 94 | + switch (protocol ?? "auto") { | |
| 95 | + case "https": | |
| 96 | + return urls.url; | |
| 97 | + case "ssh": | |
| 98 | + case "auto": | |
| 99 | + return urls.sshUrl; | |
| 100 | + } | |
| 101 | +} | |
| 102 | + | |
| 103 | +function expandHomePath(input: string, path: Path.Path): string { | |
| 104 | + if (input === "~") { | |
| 105 | + return OS.homedir(); | |
| 106 | + } | |
| 107 | + if (input.startsWith("~/") || input.startsWith("~\\")) { | |
| 108 | + return path.join(OS.homedir(), input.slice(2)); | |
| 109 | + } | |
| 110 | + return input; | |
| 111 | +} | |
| 112 | + | |
| 113 | +export const make = Effect.fn("makeSourceControlRepositoryService")(function* () { | |
| 114 | + const config = yield* ServerConfig; | |
| 115 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 116 | + const git = yield* GitVcsDriver; | |
| 117 | + const path = yield* Path.Path; | |
| 118 | + const providers = yield* SourceControlProviderRegistry; | |
| 119 | + | |
| 120 | + const ensureConcreteProvider = (input: { | |
| 121 | + readonly operation: string; | |
| 122 | + readonly provider: SourceControlProviderKind; | |
| 123 | + }) => { | |
| 124 | + if (input.provider !== "unknown") { | |
| 125 | + return Effect.succeed(input.provider); | |
| 126 | + } | |
| 127 | + | |
| 128 | + return Effect.fail( | |
| 129 | + repositoryError({ | |
| 130 | + operation: input.operation, | |
| 131 | + provider: input.provider, | |
| 132 | + detail: "Choose a source control provider before continuing.", | |
| 133 | + }), | |
| 134 | + ); | |
| 135 | + }; | |
| 136 | + | |
| 137 | + const lookupRepository = Effect.fn("SourceControlRepositoryService.lookupRepository")(function* ( | |
| 138 | + input: SourceControlRepositoryLookupInput, | |
| 139 | + ) { | |
| 140 | + const providerKind = yield* ensureConcreteProvider({ | |
| 141 | + operation: "lookupRepository", | |
| 142 | + provider: input.provider, | |
| 143 | + }); | |
| 144 | + const provider = yield* providers.get(providerKind); | |
| 145 | + const urls = yield* provider.getRepositoryCloneUrls({ | |
| 146 | + cwd: input.cwd ?? config.cwd, | |
| 147 | + repository: input.repository.trim(), | |
| 148 | + }); | |
| 149 | + return toRepositoryInfo(providerKind, urls); | |
| 150 | + }); | |
| 151 | + | |
| 152 | + const normalizeDestinationPath = Effect.fn("SourceControlRepositoryService.normalizeDestination")( | |
| 153 | + function* (destinationPath: string) { | |
| 154 | + const trimmed = destinationPath.trim(); | |
| 155 | + if (trimmed.length === 0) { | |
| 156 | + return yield* Effect.fail( | |
| 157 | + repositoryError({ | |
| 158 | + operation: "cloneRepository", | |
| 159 | + provider: "unknown", | |
| 160 | + detail: "Choose a destination path before cloning.", | |
| 161 | + }), | |
| 162 | + ); | |
| 163 | + } | |
| 164 | + | |
| 165 | + return path.resolve(expandHomePath(trimmed, path)); | |
| 166 | + }, | |
| 167 | + ); | |
| 168 | + | |
| 169 | + const prepareDestination = Effect.fn("SourceControlRepositoryService.prepareDestination")( | |
| 170 | + function* (destinationPath: string) { | |
| 171 | + const normalizedDestination = yield* normalizeDestinationPath(destinationPath); | |
| 172 | + if (yield* fileSystem.exists(normalizedDestination).pipe(Effect.orElseSucceed(() => false))) { | |
| 173 | + const entries = yield* fileSystem | |
| 174 | + .readDirectory(normalizedDestination, { recursive: false }) | |
| 175 | + .pipe( | |
| 176 | + Effect.mapError((cause) => | |
| 177 | + repositoryError({ | |
| 178 | + operation: "cloneRepository", | |
| 179 | + provider: "unknown", | |
| 180 | + detail: "Destination path already exists and is not a directory.", | |
| 181 | + cause, | |
| 182 | + }), | |
| 183 | + ), | |
| 184 | + ); | |
| 185 | + if (entries.length > 0) { | |
| 186 | + return yield* Effect.fail( | |
| 187 | + repositoryError({ | |
| 188 | + operation: "cloneRepository", | |
| 189 | + provider: "unknown", | |
| 190 | + detail: "Destination path already exists and is not empty.", | |
| 191 | + }), | |
| 192 | + ); | |
| 193 | + } | |
| 194 | + } else { | |
| 195 | + yield* fileSystem.makeDirectory(path.dirname(normalizedDestination), { recursive: true }); | |
| 196 | + } | |
| 197 | + | |
| 198 | + return { | |
| 199 | + destinationPath: normalizedDestination, | |
| 200 | + parentPath: path.dirname(normalizedDestination), | |
| 201 | + directoryName: path.basename(normalizedDestination), | |
| 202 | + }; | |
| 203 | + }, | |
| 204 | + ); | |
| 205 | + | |
| 206 | + const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* ( | |
| 207 | + input: SourceControlCloneRepositoryInput, | |
| 208 | + ) { | |
| 209 | + const preparedDestination = yield* prepareDestination(input.destinationPath); | |
| 210 | + let repository: SourceControlRepositoryInfo | null = null; | |
| 211 | + let remoteUrl = input.remoteUrl?.trim() ?? null; | |
| 212 | + let provider: SourceControlProviderKind = input.provider ?? "unknown"; | |
| 213 | + | |
| 214 | + if (input.provider && input.repository) { | |
| 215 | + repository = yield* lookupRepository({ | |
| 216 | + provider: input.provider, | |
| 217 | + repository: input.repository, | |
| 218 | + cwd: preparedDestination.parentPath, | |
| 219 | + }); | |
| 220 | + remoteUrl = selectRemoteUrl(repository, input.protocol); | |
| 221 | + provider = input.provider; | |
| 222 | + } | |
| 223 | + | |
| 224 | + if (!remoteUrl) { | |
| 225 | + return yield* Effect.fail( | |
| 226 | + repositoryError({ | |
| 227 | + operation: "cloneRepository", | |
| 228 | + provider, | |
| 229 | + detail: "Enter a repository path or clone URL before cloning.", | |
| 230 | + }), | |
| 231 | + ); | |
| 232 | + } | |
| 233 | + | |
| 234 | + yield* git.execute({ | |
| 235 | + operation: "SourceControlRepositoryService.cloneRepository", | |
| 236 | + cwd: preparedDestination.parentPath, | |
| 237 | + args: ["clone", remoteUrl, preparedDestination.directoryName], | |
| 238 | + timeoutMs: 120_000, | |
| 239 | + maxOutputBytes: 256 * 1024, | |
| 240 | + }); | |
| 241 | + | |
| 242 | + return { | |
| 243 | + cwd: preparedDestination.destinationPath, | |
| 244 | + remoteUrl, | |
| 245 | + repository, | |
| 246 | + }; | |
| 247 | + }); | |
| 248 | + | |
| 249 | + const publishRepository = Effect.fn("SourceControlRepositoryService.publishRepository")( | |
| 250 | + function* (input: SourceControlPublishRepositoryInput) { | |
| 251 | + const providerKind = yield* ensureConcreteProvider({ | |
| 252 | + operation: "publishRepository", | |
| 253 | + provider: input.provider, | |
| 254 | + }); | |
| 255 | + const provider = yield* providers.get(providerKind); | |
| 256 | + const urls = yield* provider.createRepository({ | |
| 257 | + cwd: input.cwd, | |
| 258 | + repository: input.repository.trim(), | |
| 259 | + visibility: input.visibility, | |
| 260 | + }); | |
| 261 | + const remoteUrl = selectRemoteUrl(urls, input.protocol); | |
| 262 | + const remoteName = yield* git.ensureRemote({ | |
| 263 | + cwd: input.cwd, | |
| 264 | + preferredName: input.remoteName?.trim() || "origin", | |
| 265 | + url: remoteUrl, | |
| 266 | + }); | |
| 267 | + | |
| 268 | + // An empty local repo (no commits) would make `git push HEAD:...` fail | |
| 269 | + // with an opaque "src refspec HEAD does not match any". Treat this as a | |
| 270 | + // partial success: the remote was created and wired up, but there is | |
| 271 | + // nothing to push yet. | |
| 272 | + const hasCommits = yield* git | |
| 273 | + .execute({ | |
| 274 | + operation: "SourceControlRepositoryService.publishRepository.headCheck", | |
| 275 | + cwd: input.cwd, | |
| 276 | + args: ["rev-parse", "--verify", "HEAD"], | |
| 277 | + }) | |
| 278 | + .pipe( | |
| 279 | + Effect.map(() => true), | |
| 280 | + Effect.catch(() => Effect.succeed(false)), | |
| 281 | + ); | |
| 282 | + if (!hasCommits) { | |
| 283 | + const details = yield* git | |
| 284 | + .statusDetails(input.cwd) | |
| 285 | + .pipe(Effect.catch(() => Effect.succeed(null))); | |
| 286 | + return { | |
| 287 | + repository: toRepositoryInfo(providerKind, urls), | |
| 288 | + remoteName, | |
| 289 | + remoteUrl, | |
| 290 | + branch: details?.branch ?? "main", | |
| 291 | + status: "remote_added" as const, | |
| 292 | + }; | |
| 293 | + } | |
| 294 | + | |
| 295 | + const pushResult = yield* git.pushCurrentBranch(input.cwd, null, { remoteName }); | |
| 296 | + | |
| 297 | + return { | |
| 298 | + repository: toRepositoryInfo(providerKind, urls), | |
| 299 | + remoteName, | |
| 300 | + remoteUrl, | |
| 301 | + branch: pushResult.branch, | |
| 302 | + ...(pushResult.upstreamBranch ? { upstreamBranch: pushResult.upstreamBranch } : {}), | |
| 303 | + status: "pushed" as const, | |
| 304 | + }; | |
| 305 | + }, | |
| 306 | + ); | |
| 307 | + | |
| 308 | + return SourceControlRepositoryService.of({ | |
| 309 | + lookupRepository: (input) => | |
| 310 | + lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)), | |
| 311 | + cloneRepository: (input) => | |
| 312 | + cloneRepository(input).pipe( | |
| 313 | + mapRepositoryError("cloneRepository", input.provider ?? "unknown"), | |
| 314 | + ), | |
| 315 | + publishRepository: (input) => | |
| 316 | + publishRepository(input).pipe(mapRepositoryError("publishRepository", input.provider)), | |
| 317 | + }); | |
| 318 | +}); | |
| 319 | + | |
| 320 | +export const layer = Layer.effect(SourceControlRepositoryService, make()); | |
| @@ -165,6 +165,7 @@ export interface GitVcsDriverShape { · 164 unmodified lines | ||
| 165 | 165 | readonly pushCurrentBranch: ( |
| 166 | 166 | cwd: string, |
| 167 | 167 | fallbackBranch: string | null, |
| 168 | + options?: { readonly remoteName?: string | null }, | |
| 168 | 169 | ) => Effect.Effect<GitPushResult, GitCommandError>; |
| 169 | 170 | readonly readRangeContext: ( |
| 170 | 171 | cwd: string, |
| @@ -326,5 +326,41 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { · 325 unmodified lines | ||
| 326 | 326 | assert.notEqual(badBranch.exitCode, 0); |
| 327 | 327 | }), |
| 328 | 328 | ); |
| 329 | + | |
| 330 | + it.effect("pushes to the requested remote instead of the primary remote", () => | |
| 331 | + Effect.gen(function* () { | |
| 332 | + const cwd = yield* makeTmpDir(); | |
| 333 | + const originRemote = yield* makeTmpDir("git-origin-remote-"); | |
| 334 | + const publishRemote = yield* makeTmpDir("git-publish-remote-"); | |
| 335 | + yield* initRepoWithCommit(cwd); | |
| 336 | + const driver = yield* GitVcsDriver.GitVcsDriver; | |
| 337 | + yield* git(cwd, ["branch", "-M", "main"]); | |
| 338 | + yield* git(originRemote, ["init", "--bare"]); | |
| 339 | + yield* git(publishRemote, ["init", "--bare"]); | |
| 340 | + yield* git(cwd, ["remote", "add", "origin", originRemote]); | |
| 341 | + yield* git(cwd, ["remote", "add", "origin-1", publishRemote]); | |
| 342 | + | |
| 343 | + const pushed = yield* driver.pushCurrentBranch(cwd, null, { remoteName: "origin-1" }); | |
| 344 | + | |
| 345 | + assert.deepInclude(pushed, { | |
| 346 | + status: "pushed", | |
| 347 | + branch: "main", | |
| 348 | + upstreamBranch: "origin-1/main", | |
| 349 | + setUpstream: true, | |
| 350 | + }); | |
| 351 | + assert.equal( | |
| 352 | + yield* git(publishRemote, ["log", "-1", "--pretty=%s", "main"]), | |
| 353 | + "initial commit", | |
| 354 | + ); | |
| 355 | + const originMain = yield* driver.execute({ | |
| 356 | + operation: "GitVcsDriver.test.originMainMissing", | |
| 357 | + cwd: originRemote, | |
| 358 | + args: ["show-ref", "--verify", "--quiet", "refs/heads/main"], | |
| 359 | + allowNonZeroExit: true, | |
| 360 | + timeoutMs: 10_000, | |
| 361 | + }); | |
| 362 | + assert.notEqual(originMain.exitCode, 0); | |
| 363 | + }), | |
| 364 | + ); | |
| 329 | 365 | }); |
| 330 | 366 | }); |
| @@ -1431,7 +1431,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 1430 unmodified lines | ||
| 1431 | 1431 | }); |
| 1432 | 1432 | |
| 1433 | 1433 | const pushCurrentBranch: GitVcsDriverShape["pushCurrentBranch"] = Effect.fn("pushCurrentBranch")( |
| 1434 | − function* (cwd, fallbackBranch) { | |
| 1434 | + function* (cwd, fallbackBranch, options) { | |
| 1435 | 1435 | const details = yield* statusDetails(cwd); |
| 1436 | 1436 | const branch = details.branch ?? fallbackBranch; |
| 1437 | 1437 | if (!branch) { |
| @@ -1443,6 +1443,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 5 unmodified lines | ||
| 1443 | 1443 | ); |
| 1444 | 1444 | } |
| 1445 | 1445 | |
| 1446 | + const requestedRemoteName = options?.remoteName?.trim() || null; | |
| 1447 | + if (requestedRemoteName) { | |
| 1448 | + const publishBranch = yield* resolvePublishBranchName(cwd, branch); | |
| 1449 | + yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote", cwd, [ | |
| 1450 | + "push", | |
| 1451 | + "-u", | |
| 1452 | + requestedRemoteName, | |
| 1453 | + `HEAD:refs/heads/${publishBranch}`, | |
| 1454 | + ]); | |
| 1455 | + return { | |
| 1456 | + status: "pushed" as const, | |
| 1457 | + branch, | |
| 1458 | + upstreamBranch: `${requestedRemoteName}/${publishBranch}`, | |
| 1459 | + setUpstream: true, | |
| 1460 | + }; | |
| 1461 | + } | |
| 1462 | + | |
| 1446 | 1463 | const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; |
| 1447 | 1464 | if (hasNoLocalDelta) { |
| 1448 | 1465 | if (details.hasUpstream) { |
| @@ -55,6 +55,7 @@ import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentit · 54 unmodified lines | ||
| 55 | 55 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; |
| 56 | 56 | import { ServerAuth } from "./auth/Services/ServerAuth.ts"; |
| 57 | 57 | import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; |
| 58 | +import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts"; | |
| 58 | 59 | import * as VcsProcess from "./vcs/VcsProcess.ts"; |
| 59 | 60 | import { |
| 60 | 61 | BootstrapCredentialService, |
| @@ -154,6 +155,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => · 93 unmodified lines | ||
| 154 | 155 | const serverEnvironment = yield* ServerEnvironment; |
| 155 | 156 | const serverAuth = yield* ServerAuth; |
| 156 | 157 | const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; |
| 158 | + const sourceControlRepositories = yield* SourceControlRepositoryService; | |
| 157 | 159 | const bootstrapCredentials = yield* BootstrapCredentialService; |
| 158 | 160 | const sessions = yield* SessionCredentialService; |
| 159 | 161 | const serverCommandId = (tag: string) => |
| @@ -794,6 +796,32 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => · 634 unmodified lines | ||
| 794 | 796 | "rpc.aggregate": "server", |
| 795 | 797 | }, |
| 796 | 798 | ), |
| 799 | + [WS_METHODS.sourceControlLookupRepository]: (input) => | |
| 800 | + observeRpcEffect( | |
| 801 | + WS_METHODS.sourceControlLookupRepository, | |
| 802 | + sourceControlRepositories.lookupRepository(input), | |
| 803 | + { | |
| 804 | + "rpc.aggregate": "source-control", | |
| 805 | + }, | |
| 806 | + ), | |
| 807 | + [WS_METHODS.sourceControlCloneRepository]: (input) => | |
| 808 | + observeRpcEffect( | |
| 809 | + WS_METHODS.sourceControlCloneRepository, | |
| 810 | + sourceControlRepositories.cloneRepository(input), | |
| 811 | + { | |
| 812 | + "rpc.aggregate": "source-control", | |
| 813 | + }, | |
| 814 | + ), | |
| 815 | + [WS_METHODS.sourceControlPublishRepository]: (input) => | |
| 816 | + observeRpcEffect( | |
| 817 | + WS_METHODS.sourceControlPublishRepository, | |
| 818 | + sourceControlRepositories | |
| 819 | + .publishRepository(input) | |
| 820 | + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), | |
| 821 | + { | |
| 822 | + "rpc.aggregate": "source-control", | |
| 823 | + }, | |
| 824 | + ), | |
| 797 | 825 | [WS_METHODS.projectsSearchEntries]: (input) => |
| 798 | 826 | observeRpcEffect( |
| 799 | 827 | WS_METHODS.projectsSearchEntries, |
| @@ -206,6 +206,7 @@ function createMockEnvironmentApi(input: { · 205 unmodified lines | ||
| 206 | 206 | filesystem: { |
| 207 | 207 | browse: input.browse, |
| 208 | 208 | }, |
| 209 | + sourceControl: {} as EnvironmentApi["sourceControl"], | |
| 209 | 210 | vcs: {} as EnvironmentApi["vcs"], |
| 210 | 211 | git: {} as EnvironmentApi["git"], |
| 211 | 212 | orchestration: { |
| @@ -4257,8 +4258,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 4045 unmodified lines | ||
| 4257 | 4258 | }); |
| 4258 | 4259 | |
| 4259 | 4260 | try { |
| 4260 | − await waitForServerConfigToApply(); | |
| 4261 | − await waitForCommandPaletteShortcutLabel(); | |
| 4261 | + await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); | |
| 4262 | 4262 | const palette = page.getByTestId("command-palette"); |
| 4263 | 4263 | await openCommandPaletteFromTrigger(); |
| 4264 | 4264 | |
| @@ -4310,8 +4310,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 45 unmodified lines | ||
| 4310 | 4310 | }); |
| 4311 | 4311 | |
| 4312 | 4312 | try { |
| 4313 | − await waitForServerConfigToApply(); | |
| 4314 | − await waitForCommandPaletteShortcutLabel(); | |
| 4313 | + await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); | |
| 4315 | 4314 | const palette = page.getByTestId("command-palette"); |
| 4316 | 4315 | await openCommandPaletteFromTrigger(); |
| 4317 | 4316 | |
| @@ -4384,13 +4383,13 @@ describe("ChatView timeline estimator parity (full app)", () => { · 66 unmodified lines | ||
| 4384 | 4383 | }); |
| 4385 | 4384 | |
| 4386 | 4385 | try { |
| 4387 | − await waitForServerConfigToApply(); | |
| 4388 | − await waitForCommandPaletteShortcutLabel(); | |
| 4386 | + await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); | |
| 4389 | 4387 | const palette = page.getByTestId("command-palette"); |
| 4390 | 4388 | await openCommandPaletteFromTrigger(); |
| 4391 | 4389 | |
| 4392 | 4390 | await expect.element(palette).toBeInTheDocument(); |
| 4393 | 4391 | await palette.getByText("Add project", { exact: true }).click(); |
| 4392 | + await palette.getByText("Local folder", { exact: true }).click(); | |
| 4394 | 4393 | |
| 4395 | 4394 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); |
| 4396 | 4395 | await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/"); |
| @@ -4437,6 +4436,126 @@ describe("ChatView timeline estimator parity (full app)", () => { · 40 unmodified lines | ||
| 4437 | 4436 | } |
| 4438 | 4437 | }); |
| 4439 | 4438 | |
| 4439 | + it("shows clone destination controls after resolving an add project repository", async () => { | |
| 4440 | + const mounted = await mountChatView({ | |
| 4441 | + viewport: DEFAULT_VIEWPORT, | |
| 4442 | + snapshot: createSnapshotForTargetUser({ | |
| 4443 | + targetMessageId: "msg-user-command-palette-add-project-remote" as MessageId, | |
| 4444 | + targetText: "command palette add project remote", | |
| 4445 | + }), | |
| 4446 | + configureFixture: (nextFixture) => { | |
| 4447 | + nextFixture.serverConfig = { | |
| 4448 | + ...nextFixture.serverConfig, | |
| 4449 | + keybindings: [ | |
| 4450 | + { | |
| 4451 | + command: "commandPalette.toggle", | |
| 4452 | + shortcut: { | |
| 4453 | + key: "k", | |
| 4454 | + metaKey: false, | |
| 4455 | + ctrlKey: false, | |
| 4456 | + shiftKey: false, | |
| 4457 | + altKey: false, | |
| 4458 | + modKey: true, | |
| 4459 | + }, | |
| 4460 | + whenAst: { | |
| 4461 | + type: "not", | |
| 4462 | + node: { type: "identifier", name: "terminalFocus" }, | |
| 4463 | + }, | |
| 4464 | + }, | |
| 4465 | + ], | |
| 4466 | + }; | |
| 4467 | + }, | |
| 4468 | + resolveRpc: (body) => { | |
| 4469 | + if (body._tag === WS_METHODS.filesystemBrowse) { | |
| 4470 | + return { | |
| 4471 | + parentPath: "~/", | |
| 4472 | + entries: [{ name: "Development", fullPath: "~/Development" }], | |
| 4473 | + }; | |
| 4474 | + } | |
| 4475 | + | |
| 4476 | + if (body._tag === WS_METHODS.sourceControlLookupRepository) { | |
| 4477 | + return { | |
| 4478 | + provider: "github", | |
| 4479 | + nameWithOwner: "t3-oss/t3-env", | |
| 4480 | + url: "https://github.com/t3-oss/t3-env", | |
| 4481 | + sshUrl: "git@github.com:t3-oss/t3-env.git", | |
| 4482 | + }; | |
| 4483 | + } | |
| 4484 | + | |
| 4485 | + if (body._tag === WS_METHODS.sourceControlCloneRepository) { | |
| 4486 | + return { | |
| 4487 | + cwd: body.destinationPath, | |
| 4488 | + remoteUrl: body.remoteUrl, | |
| 4489 | + repository: null, | |
| 4490 | + }; | |
| 4491 | + } | |
| 4492 | + | |
| 4493 | + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { | |
| 4494 | + return { | |
| 4495 | + sequence: fixture.snapshot.snapshotSequence + 1, | |
| 4496 | + }; | |
| 4497 | + } | |
| 4498 | + | |
| 4499 | + return undefined; | |
| 4500 | + }, | |
| 4501 | + }); | |
| 4502 | + | |
| 4503 | + try { | |
| 4504 | + await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); | |
| 4505 | + const palette = page.getByTestId("command-palette"); | |
| 4506 | + await openCommandPaletteFromTrigger(); | |
| 4507 | + | |
| 4508 | + await expect.element(palette).toBeInTheDocument(); | |
| 4509 | + await palette.getByText("Add project", { exact: true }).click(); | |
| 4510 | + await palette.getByText("GitHub repository", { exact: true }).click(); | |
| 4511 | + | |
| 4512 | + const repositoryInput = await waitForCommandPaletteInput( | |
| 4513 | + "Enter GitHub repository (owner/repo)", | |
| 4514 | + ); | |
| 4515 | + await page.getByPlaceholder("Enter GitHub repository (owner/repo)").fill("t3-oss/t3-env"); | |
| 4516 | + await dispatchInputKey(repositoryInput, { key: "Enter" }); | |
| 4517 | + | |
| 4518 | + await vi.waitFor( | |
| 4519 | + () => { | |
| 4520 | + const clonePathInput = document.querySelector<HTMLInputElement>( | |
| 4521 | + 'input[placeholder="Enter path (e.g. ~/projects/my-app)"]', | |
| 4522 | + ); | |
| 4523 | + expect(clonePathInput?.value).toBe("~/"); | |
| 4524 | + expect(document.body.textContent).toContain("Repository"); | |
| 4525 | + expect(document.body.textContent).toContain("t3-oss/t3-env"); | |
| 4526 | + expect(document.body.textContent).toContain("https://github.com/t3-oss/t3-env"); | |
| 4527 | + expect(document.body.textContent).toContain("Select where to clone"); | |
| 4528 | + expect(document.body.textContent).toContain("Development"); | |
| 4529 | + expect(document.body.textContent).toContain("Clone"); | |
| 4530 | + }, | |
| 4531 | + { timeout: 8_000, interval: 16 }, | |
| 4532 | + ); | |
| 4533 | + | |
| 4534 | + await page | |
| 4535 | + .getByPlaceholder("Enter path (e.g. ~/projects/my-app)") | |
| 4536 | + .fill("~/Development/t3env"); | |
| 4537 | + const clonePathInput = await waitForCommandPaletteInput( | |
| 4538 | + "Enter path (e.g. ~/projects/my-app)", | |
| 4539 | + ); | |
| 4540 | + await dispatchInputKey(clonePathInput, { key: "Enter" }); | |
| 4541 | + | |
| 4542 | + await vi.waitFor( | |
| 4543 | + () => { | |
| 4544 | + const cloneRequest = wsRequests.find( | |
| 4545 | + (request) => request._tag === WS_METHODS.sourceControlCloneRepository, | |
| 4546 | + ) as { destinationPath?: string; remoteUrl?: string } | undefined; | |
| 4547 | + expect(cloneRequest).toMatchObject({ | |
| 4548 | + remoteUrl: "git@github.com:t3-oss/t3-env.git", | |
| 4549 | + destinationPath: "~/Development/t3env", | |
| 4550 | + }); | |
| 4551 | + }, | |
| 4552 | + { timeout: 8_000, interval: 16 }, | |
| 4553 | + ); | |
| 4554 | + } finally { | |
| 4555 | + await mounted.cleanup(); | |
| 4556 | + } | |
| 4557 | + }); | |
| 4558 | + | |
| 4440 | 4559 | it("opens add project browse mode from the sidebar add button", async () => { |
| 4441 | 4560 | const mounted = await mountChatView({ |
| 4442 | 4561 | viewport: DEFAULT_VIEWPORT, |
| @@ -4789,6 +4908,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 346 unmodified lines | ||
| 4789 | 4908 | |
| 4790 | 4909 | await expect.element(palette).toBeInTheDocument(); |
| 4791 | 4910 | await palette.getByText("Add project", { exact: true }).click(); |
| 4911 | + await palette.getByText("Local folder", { exact: true }).click(); | |
| 4792 | 4912 | await expect.element(palette.getByText("Environments", { exact: true })).toBeInTheDocument(); |
| 4793 | 4913 | await expect |
| 4794 | 4914 | .element(palette.getByText("This device", { exact: true }).first()) |
| @@ -5004,6 +5124,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 209 unmodified lines | ||
| 5004 | 5124 | |
| 5005 | 5125 | await expect.element(palette).toBeInTheDocument(); |
| 5006 | 5126 | await palette.getByText("Add project", { exact: true }).click(); |
| 5127 | + await palette.getByText("Local folder", { exact: true }).click(); | |
| 5007 | 5128 | |
| 5008 | 5129 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); |
| 5009 | 5130 | await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/"); |
| @@ -7,6 +7,8 @@ import { · 6 unmodified lines | ||
| 7 | 7 | type FilesystemBrowseResult, |
| 8 | 8 | type ProjectId, |
| 9 | 9 | ProviderInstanceId, |
| 10 | + type SourceControlProviderKind, | |
| 11 | + type SourceControlRepositoryInfo, | |
| 10 | 12 | } from "@t3tools/contracts"; |
| 11 | 13 | import { useQuery, useQueryClient } from "@tanstack/react-query"; |
| 12 | 14 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| @@ -17,6 +19,7 @@ import { · 4 unmodified lines | ||
| 17 | 19 | CornerLeftUpIcon, |
| 18 | 20 | FolderIcon, |
| 19 | 21 | FolderPlusIcon, |
| 22 | + LinkIcon, | |
| 20 | 23 | MessageSquareIcon, |
| 21 | 24 | SettingsIcon, |
| 22 | 25 | SquarePenIcon, |
| @@ -89,6 +92,7 @@ import { · 66 unmodified lines | ||
| 89 | 92 | } from "./CommandPalette.logic"; |
| 90 | 93 | import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; |
| 91 | 94 | import { CommandPaletteResults } from "./CommandPaletteResults"; |
| 95 | +import { GitHubIcon, GitLabIcon } from "./Icons"; | |
| 92 | 96 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 93 | 97 | import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; |
| 94 | 98 | import { useServerKeybindings } from "../rpc/serverState"; |
| @@ -139,6 +143,70 @@ interface AddProjectEnvironmentOption { · 44 unmodified lines | ||
| 139 | 143 | readonly isPrimary: boolean; |
| 140 | 144 | } |
| 141 | 145 | |
| 146 | +type AddProjectRemoteProviderKind = Extract<SourceControlProviderKind, "github" | "gitlab">; | |
| 147 | +type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; | |
| 148 | + | |
| 149 | +type AddProjectCloneFlow = | |
| 150 | + | { | |
| 151 | + readonly step: "repository"; | |
| 152 | + readonly environmentId: EnvironmentId; | |
| 153 | + readonly source: AddProjectRemoteSource; | |
| 154 | + } | |
| 155 | + | { | |
| 156 | + readonly step: "confirm"; | |
| 157 | + readonly environmentId: EnvironmentId; | |
| 158 | + readonly source: AddProjectRemoteSource; | |
| 159 | + readonly repositoryInput: string; | |
| 160 | + readonly repository: SourceControlRepositoryInfo | null; | |
| 161 | + readonly remoteUrl: string; | |
| 162 | + }; | |
| 163 | + | |
| 164 | +const REMOTE_PROJECT_SOURCES: ReadonlyArray<AddProjectRemoteSource> = ["github", "gitlab", "url"]; | |
| 165 | + | |
| 166 | +function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { | |
| 167 | + switch (source) { | |
| 168 | + case "github": | |
| 169 | + return "GitHub"; | |
| 170 | + case "gitlab": | |
| 171 | + return "GitLab"; | |
| 172 | + case "url": | |
| 173 | + return "Git URL"; | |
| 174 | + } | |
| 175 | +} | |
| 176 | + | |
| 177 | +function remoteProjectSourceProvider( | |
| 178 | + source: AddProjectRemoteSource, | |
| 179 | +): AddProjectRemoteProviderKind | null { | |
| 180 | + return source === "url" ? null : source; | |
| 181 | +} | |
| 182 | + | |
| 183 | +function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: string): ReactNode { | |
| 184 | + switch (source) { | |
| 185 | + case "github": | |
| 186 | + return <GitHubIcon className={className} />; | |
| 187 | + case "gitlab": | |
| 188 | + return <GitLabIcon className={className} />; | |
| 189 | + case "url": | |
| 190 | + return <LinkIcon className={className} />; | |
| 191 | + } | |
| 192 | +} | |
| 193 | + | |
| 194 | +function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string | null { | |
| 195 | + if (!flow) return null; | |
| 196 | + if (flow.step === "confirm") return null; | |
| 197 | + if (flow.source === "url") { | |
| 198 | + return "Enter Git clone URL"; | |
| 199 | + } | |
| 200 | + return `Enter ${remoteProjectSourceLabel(flow.source)} repository (owner/repo)`; | |
| 201 | +} | |
| 202 | + | |
| 203 | +function errorMessage(error: unknown): string { | |
| 204 | + if (error instanceof Error && error.message.trim().length > 0) { | |
| 205 | + return error.message; | |
| 206 | + } | |
| 207 | + return "An error occurred."; | |
| 208 | +} | |
| 209 | + | |
| 142 | 210 | export function CommandPalette({ children }: { children: ReactNode }) { |
| 143 | 211 | const open = useCommandPaletteStore((store) => store.open); |
| 144 | 212 | const setOpen = useCommandPaletteStore((store) => store.setOpen); |
| @@ -227,6 +295,9 @@ function OpenCommandPaletteDialog() { · 82 unmodified lines | ||
| 227 | 295 | null, |
| 228 | 296 | ); |
| 229 | 297 | const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false); |
| 298 | + const [addProjectCloneFlow, setAddProjectCloneFlow] = useState<AddProjectCloneFlow | null>(null); | |
| 299 | + const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); | |
| 300 | + const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); | |
| 230 | 301 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 231 | 302 | const primaryEnvironmentLabel = readPrimaryEnvironmentDescriptor()?.label ?? null; |
| 232 | 303 | const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((state) => state.byId); |
| @@ -295,7 +366,10 @@ function OpenCommandPaletteDialog() { · 62 unmodified lines | ||
| 295 | 366 | : null; |
| 296 | 367 | return getEnvironmentBrowsePlatform(os); |
| 297 | 368 | }, [browseEnvironmentId, primaryEnvironmentId, savedEnvironmentRuntimeById]); |
| 298 | − const isBrowsing = isFilesystemBrowseQuery(query, browseEnvironmentPlatform); | |
| 369 | + const isRemoteProjectCloneFlow = addProjectCloneFlow !== null; | |
| 370 | + const isRemoteProjectRepositoryStep = addProjectCloneFlow?.step === "repository"; | |
| 371 | + const isBrowsing = | |
| 372 | + !isRemoteProjectRepositoryStep && isFilesystemBrowseQuery(query, browseEnvironmentPlatform); | |
| 299 | 373 | const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); |
| 300 | 374 | const getAddProjectInitialQueryForEnvironment = useCallback( |
| 301 | 375 | (environmentId: EnvironmentId | null): string => { |
| @@ -541,6 +615,7 @@ function OpenCommandPaletteDialog() { · 239 unmodified lines | ||
| 541 | 615 | } |
| 542 | 616 | |
| 543 | 617 | function popView(): void { |
| 618 | + setAddProjectCloneFlow(null); | |
| 544 | 619 | if (viewStack.length <= 1) { |
| 545 | 620 | setAddProjectEnvironmentId(null); |
| 546 | 621 | } |
| @@ -560,6 +635,7 @@ function OpenCommandPaletteDialog() { · 13 unmodified lines | ||
| 560 | 635 | const startAddProjectBrowse = useCallback( |
| 561 | 636 | (environmentId: EnvironmentId): void => { |
| 562 | 637 | setAddProjectEnvironmentId(environmentId); |
| 638 | + setAddProjectCloneFlow(null); | |
| 563 | 639 | pushPaletteView({ |
| 564 | 640 | addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />, |
| 565 | 641 | groups: [], |
| @@ -569,6 +645,19 @@ function OpenCommandPaletteDialog() { · 3 unmodified lines | ||
| 569 | 645 | [getAddProjectInitialQueryForEnvironment], |
| 570 | 646 | ); |
| 571 | 647 | |
| 648 | + const startAddProjectClone = useCallback( | |
| 649 | + (environmentId: EnvironmentId, source: AddProjectRemoteSource): void => { | |
| 650 | + setAddProjectEnvironmentId(environmentId); | |
| 651 | + setAddProjectCloneFlow({ step: "repository", environmentId, source }); | |
| 652 | + pushPaletteView({ | |
| 653 | + addonIcon: remoteProjectSourceIcon(source, ADDON_ICON_CLASS), | |
| 654 | + groups: [], | |
| 655 | + initialQuery: "", | |
| 656 | + }); | |
| 657 | + }, | |
| 658 | + [], | |
| 659 | + ); | |
| 660 | + | |
| 572 | 661 | const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( |
| 573 | 662 | (option) => ({ |
| 574 | 663 | kind: "action", |
| @@ -595,6 +684,156 @@ function OpenCommandPaletteDialog() { · 20 unmodified lines | ||
| 595 | 684 | [addProjectEnvironmentItems], |
| 596 | 685 | ); |
| 597 | 686 | |
| 687 | + const addProjectRemoteEnvironmentGroups = useMemo< | |
| 688 | + Record<AddProjectRemoteSource, CommandPaletteView["groups"]> | |
| 689 | + >(() => { | |
| 690 | + const buildGroups = (source: AddProjectRemoteSource): CommandPaletteView["groups"] => [ | |
| 691 | + { | |
| 692 | + value: `environments:${source}`, | |
| 693 | + label: "Environments", | |
| 694 | + items: addProjectEnvironmentOptions.map((option) => ({ | |
| 695 | + kind: "action" as const, | |
| 696 | + value: `action:add-project:${source}:environment:${option.environmentId}`, | |
| 697 | + searchTerms: [ | |
| 698 | + option.label, | |
| 699 | + option.environmentId, | |
| 700 | + option.isPrimary ? "this device" : "", | |
| 701 | + remoteProjectSourceLabel(source), | |
| 702 | + ], | |
| 703 | + title: option.label, | |
| 704 | + description: option.isPrimary ? "This device" : option.environmentId, | |
| 705 | + icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), | |
| 706 | + keepOpen: true, | |
| 707 | + run: async () => { | |
| 708 | + startAddProjectClone(option.environmentId, source); | |
| 709 | + }, | |
| 710 | + })), | |
| 711 | + }, | |
| 712 | + ]; | |
| 713 | + | |
| 714 | + return { | |
| 715 | + github: buildGroups("github"), | |
| 716 | + gitlab: buildGroups("gitlab"), | |
| 717 | + url: buildGroups("url"), | |
| 718 | + }; | |
| 719 | + }, [addProjectEnvironmentOptions, startAddProjectClone]); | |
| 720 | + | |
| 721 | + const openAddProjectCloneFlow = useCallback( | |
| 722 | + (source: AddProjectRemoteSource) => { | |
| 723 | + if (addProjectEnvironmentOptions.length > 1) { | |
| 724 | + pushPaletteView({ | |
| 725 | + addonIcon: remoteProjectSourceIcon(source, ADDON_ICON_CLASS), | |
| 726 | + groups: addProjectRemoteEnvironmentGroups[source], | |
| 727 | + }); | |
| 728 | + return; | |
| 729 | + } | |
| 730 | + | |
| 731 | + const environmentId = defaultAddProjectEnvironmentId; | |
| 732 | + if (!environmentId) { | |
| 733 | + toastManager.add( | |
| 734 | + stackedThreadToast({ | |
| 735 | + type: "error", | |
| 736 | + title: "Unable to clone project", | |
| 737 | + description: "No environment is available.", | |
| 738 | + }), | |
| 739 | + ); | |
| 740 | + return; | |
| 741 | + } | |
| 742 | + | |
| 743 | + startAddProjectClone(environmentId, source); | |
| 744 | + }, | |
| 745 | + [ | |
| 746 | + addProjectEnvironmentOptions.length, | |
| 747 | + addProjectRemoteEnvironmentGroups, | |
| 748 | + defaultAddProjectEnvironmentId, | |
| 749 | + startAddProjectClone, | |
| 750 | + ], | |
| 751 | + ); | |
| 752 | + | |
| 753 | + const addProjectSourceGroups = useMemo<CommandPaletteView["groups"]>(() => { | |
| 754 | + const sourceItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = []; | |
| 755 | + | |
| 756 | + if (addProjectEnvironmentOptions.length > 1) { | |
| 757 | + sourceItems.push({ | |
| 758 | + kind: "submenu", | |
| 759 | + value: "action:add-project:local", | |
| 760 | + searchTerms: ["local", "folder", "directory", "browse", "environment"], | |
| 761 | + title: "Local folder", | |
| 762 | + description: "Browse a folder on disk", | |
| 763 | + icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, | |
| 764 | + addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />, | |
| 765 | + groups: addProjectEnvironmentGroups, | |
| 766 | + }); | |
| 767 | + } else { | |
| 768 | + sourceItems.push({ | |
| 769 | + kind: "action", | |
| 770 | + value: "action:add-project:local", | |
| 771 | + searchTerms: ["local", "folder", "directory", "browse"], | |
| 772 | + title: "Local folder", | |
| 773 | + description: "Browse a folder on disk", | |
| 774 | + icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, | |
| 775 | + keepOpen: true, | |
| 776 | + run: async () => { | |
| 777 | + const environmentId = defaultAddProjectEnvironmentId; | |
| 778 | + if (!environmentId) { | |
| 779 | + toastManager.add( | |
| 780 | + stackedThreadToast({ | |
| 781 | + type: "error", | |
| 782 | + title: "Unable to browse projects", | |
| 783 | + description: "No environment is available.", | |
| 784 | + }), | |
| 785 | + ); | |
| 786 | + return; | |
| 787 | + } | |
| 788 | + startAddProjectBrowse(environmentId); | |
| 789 | + }, | |
| 790 | + }); | |
| 791 | + } | |
| 792 | + | |
| 793 | + for (const source of REMOTE_PROJECT_SOURCES) { | |
| 794 | + const label = remoteProjectSourceLabel(source); | |
| 795 | + const title = source === "url" ? "Git URL" : `${label} repository`; | |
| 796 | + const description = | |
| 797 | + source === "url" ? "Clone from a remote URL" : `Clone ${label} owner/repo`; | |
| 798 | + | |
| 799 | + if (addProjectEnvironmentOptions.length > 1) { | |
| 800 | + sourceItems.push({ | |
| 801 | + kind: "submenu", | |
| 802 | + value: `action:add-project:${source}`, | |
| 803 | + searchTerms: ["clone", "remote", "repository", "repo", "git", label], | |
| 804 | + title, | |
| 805 | + description, | |
| 806 | + icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), | |
| 807 | + addonIcon: remoteProjectSourceIcon(source, ADDON_ICON_CLASS), | |
| 808 | + groups: addProjectRemoteEnvironmentGroups[source], | |
| 809 | + }); | |
| 810 | + continue; | |
| 811 | + } | |
| 812 | + | |
| 813 | + sourceItems.push({ | |
| 814 | + kind: "action", | |
| 815 | + value: `action:add-project:${source}`, | |
| 816 | + searchTerms: ["clone", "remote", "repository", "repo", "git", label], | |
| 817 | + title, | |
| 818 | + description, | |
| 819 | + icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), | |
| 820 | + keepOpen: true, | |
| 821 | + run: async () => { | |
| 822 | + openAddProjectCloneFlow(source); | |
| 823 | + }, | |
| 824 | + }); | |
| 825 | + } | |
| 826 | + | |
| 827 | + return [{ value: "sources", label: "Sources", items: sourceItems }]; | |
| 828 | + }, [ | |
| 829 | + addProjectEnvironmentGroups, | |
| 830 | + addProjectEnvironmentOptions.length, | |
| 831 | + addProjectRemoteEnvironmentGroups, | |
| 832 | + defaultAddProjectEnvironmentId, | |
| 833 | + openAddProjectCloneFlow, | |
| 834 | + startAddProjectBrowse, | |
| 835 | + ]); | |
| 836 | + | |
| 598 | 837 | const openAddProjectFlow = useCallback(() => { |
| 599 | 838 | if (addProjectEnvironmentOptions.length > 1) { |
| 600 | 839 | pushPaletteView({ |
| @@ -674,29 +913,29 @@ function OpenCommandPaletteDialog() { · 73 unmodified lines | ||
| 674 | 913 | }); |
| 675 | 914 | } |
| 676 | 915 | |
| 677 | − if (addProjectEnvironmentOptions.length > 1) { | |
| 678 | − actionItems.push({ | |
| 679 | − kind: "submenu", | |
| 680 | − value: "action:add-project", | |
| 681 | − searchTerms: ["add project", "folder", "directory", "browse", "environment"], | |
| 682 | − title: "Add project", | |
| 683 | − icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, | |
| 684 | − addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />, | |
| 685 | − groups: addProjectEnvironmentGroups, | |
| 686 | − }); | |
| 687 | − } else { | |
| 688 | − actionItems.push({ | |
| 689 | − kind: "action", | |
| 690 | − value: "action:add-project", | |
| 691 | − searchTerms: ["add project", "folder", "directory", "browse"], | |
| 692 | − title: "Add project", | |
| 693 | − icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, | |
| 694 | − keepOpen: true, | |
| 695 | − run: async () => { | |
| 696 | − openAddProjectFlow(); | |
| 697 | − }, | |
| 698 | − }); | |
| 699 | − } | |
| 916 | + actionItems.push({ | |
| 917 | + kind: "submenu", | |
| 918 | + value: "action:add-project", | |
| 919 | + searchTerms: [ | |
| 920 | + "add project", | |
| 921 | + "folder", | |
| 922 | + "directory", | |
| 923 | + "browse", | |
| 924 | + "clone", | |
| 925 | + "remote", | |
| 926 | + "repository", | |
| 927 | + "repo", | |
| 928 | + "git", | |
| 929 | + "github", | |
| 930 | + "gitlab", | |
| 931 | + "url", | |
| 932 | + "environment", | |
| 933 | + ], | |
| 934 | + title: "Add project", | |
| 935 | + icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, | |
| 936 | + addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />, | |
| 937 | + groups: addProjectSourceGroups, | |
| 938 | + }); | |
| 700 | 939 | |
| 701 | 940 | actionItems.push({ |
| 702 | 941 | kind: "action", |
| @@ -820,6 +1059,137 @@ function OpenCommandPaletteDialog() { · 117 unmodified lines | ||
| 820 | 1059 | ], |
| 821 | 1060 | ); |
| 822 | 1061 | |
| 1062 | + function getDefaultCloneParentPath(environmentId: EnvironmentId): string { | |
| 1063 | + return getAddProjectInitialQueryForEnvironment(environmentId); | |
| 1064 | + } | |
| 1065 | + | |
| 1066 | + async function submitAddProjectCloneFlow(destinationPathInput?: string): Promise<void> { | |
| 1067 | + if (!addProjectCloneFlow) { | |
| 1068 | + return; | |
| 1069 | + } | |
| 1070 | + | |
| 1071 | + const api = readEnvironmentApi(addProjectCloneFlow.environmentId); | |
| 1072 | + if (!api) { | |
| 1073 | + toastManager.add( | |
| 1074 | + stackedThreadToast({ | |
| 1075 | + type: "error", | |
| 1076 | + title: "Unable to clone project", | |
| 1077 | + description: "Environment API is not available.", | |
| 1078 | + }), | |
| 1079 | + ); | |
| 1080 | + return; | |
| 1081 | + } | |
| 1082 | + | |
| 1083 | + if (addProjectCloneFlow.step === "repository") { | |
| 1084 | + const rawRepository = query.trim(); | |
| 1085 | + if (rawRepository.length === 0 || isRemoteProjectLookingUp) { | |
| 1086 | + return; | |
| 1087 | + } | |
| 1088 | + | |
| 1089 | + const provider = remoteProjectSourceProvider(addProjectCloneFlow.source); | |
| 1090 | + if (!provider) { | |
| 1091 | + const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId); | |
| 1092 | + setAddProjectCloneFlow({ | |
| 1093 | + step: "confirm", | |
| 1094 | + environmentId: addProjectCloneFlow.environmentId, | |
| 1095 | + source: addProjectCloneFlow.source, | |
| 1096 | + repositoryInput: rawRepository, | |
| 1097 | + repository: null, | |
| 1098 | + remoteUrl: rawRepository, | |
| 1099 | + }); | |
| 1100 | + setHighlightedItemValue(null); | |
| 1101 | + setQuery(destinationPath); | |
| 1102 | + setBrowseGeneration((generation) => generation + 1); | |
| 1103 | + return; | |
| 1104 | + } | |
| 1105 | + | |
| 1106 | + setIsRemoteProjectLookingUp(true); | |
| 1107 | + try { | |
| 1108 | + const repository = await api.sourceControl.lookupRepository({ | |
| 1109 | + provider, | |
| 1110 | + repository: rawRepository, | |
| 1111 | + }); | |
| 1112 | + const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId); | |
| 1113 | + setAddProjectCloneFlow({ | |
| 1114 | + step: "confirm", | |
| 1115 | + environmentId: addProjectCloneFlow.environmentId, | |
| 1116 | + source: addProjectCloneFlow.source, | |
| 1117 | + repositoryInput: rawRepository, | |
| 1118 | + repository, | |
| 1119 | + remoteUrl: repository.sshUrl, | |
| 1120 | + }); | |
| 1121 | + setHighlightedItemValue(null); | |
| 1122 | + setQuery(destinationPath); | |
| 1123 | + setBrowseGeneration((generation) => generation + 1); | |
| 1124 | + } catch (error) { | |
| 1125 | + toastManager.add( | |
| 1126 | + stackedThreadToast({ | |
| 1127 | + type: "error", | |
| 1128 | + title: "Repository lookup failed", | |
| 1129 | + description: errorMessage(error), | |
| 1130 | + }), | |
| 1131 | + ); | |
| 1132 | + } finally { | |
| 1133 | + setIsRemoteProjectLookingUp(false); | |
| 1134 | + } | |
| 1135 | + return; | |
| 1136 | + } | |
| 1137 | + | |
| 1138 | + const rawDestination = (destinationPathInput ?? query).trim(); | |
| 1139 | + if (rawDestination.length === 0 || isRemoteProjectCloning) { | |
| 1140 | + return; | |
| 1141 | + } | |
| 1142 | + | |
| 1143 | + if (isUnsupportedWindowsProjectPath(rawDestination, browseEnvironmentPlatform)) { | |
| 1144 | + toastManager.add( | |
| 1145 | + stackedThreadToast({ | |
| 1146 | + type: "error", | |
| 1147 | + title: "Clone failed", | |
| 1148 | + description: "Windows-style paths are only supported on Windows.", | |
| 1149 | + }), | |
| 1150 | + ); | |
| 1151 | + return; | |
| 1152 | + } | |
| 1153 | + | |
| 1154 | + if (isExplicitRelativeProjectPath(rawDestination) && !currentProjectCwdForBrowse) { | |
| 1155 | + toastManager.add( | |
| 1156 | + stackedThreadToast({ | |
| 1157 | + type: "error", | |
| 1158 | + title: "Clone failed", | |
| 1159 | + description: "Relative paths require an active project.", | |
| 1160 | + }), | |
| 1161 | + ); | |
| 1162 | + return; | |
| 1163 | + } | |
| 1164 | + | |
| 1165 | + const destinationPath = resolveProjectPathForDispatch( | |
| 1166 | + rawDestination, | |
| 1167 | + currentProjectCwdForBrowse, | |
| 1168 | + ); | |
| 1169 | + if (destinationPath.length === 0) { | |
| 1170 | + return; | |
| 1171 | + } | |
| 1172 | + | |
| 1173 | + setIsRemoteProjectCloning(true); | |
| 1174 | + try { | |
| 1175 | + const result = await api.sourceControl.cloneRepository({ | |
| 1176 | + remoteUrl: addProjectCloneFlow.remoteUrl, | |
| 1177 | + destinationPath, | |
| 1178 | + }); | |
| 1179 | + await handleAddProject(result.cwd); | |
| 1180 | + } catch (error) { | |
| 1181 | + toastManager.add( | |
| 1182 | + stackedThreadToast({ | |
| 1183 | + type: "error", | |
| 1184 | + title: "Clone failed", | |
| 1185 | + description: errorMessage(error), | |
| 1186 | + }), | |
| 1187 | + ); | |
| 1188 | + } finally { | |
| 1189 | + setIsRemoteProjectCloning(false); | |
| 1190 | + } | |
| 1191 | + } | |
| 1192 | + | |
| 823 | 1193 | function browseTo(name: string): void { |
| 824 | 1194 | const nextQuery = appendBrowsePathSegment(query, name); |
| 825 | 1195 | setHighlightedItemValue(null); |
| @@ -858,13 +1228,38 @@ function OpenCommandPaletteDialog() { · 32 unmodified lines | ||
| 858 | 1228 | browseUp, |
| 859 | 1229 | browseTo, |
| 860 | 1230 | }); |
| 1231 | + const cloneDestinationBrowseGroups = useMemo( | |
| 1232 | + () => | |
| 1233 | + browseGroups.map((group) => | |
| 1234 | + group.value === "directories" ? { ...group, label: "Select where to clone" } : group, | |
| 1235 | + ), | |
| 1236 | + [browseGroups], | |
| 1237 | + ); | |
| 1238 | + | |
| 1239 | + const remoteProjectContext = useMemo(() => { | |
| 1240 | + if (addProjectCloneFlow?.step !== "confirm") { | |
| 1241 | + return null; | |
| 1242 | + } | |
| 861 | 1243 | |
| 862 | − let displayedGroups = filteredGroups; | |
| 863 | − if (isBrowsing) { | |
| 1244 | + return { | |
| 1245 | + title: addProjectCloneFlow.repository?.nameWithOwner ?? addProjectCloneFlow.repositoryInput, | |
| 1246 | + description: addProjectCloneFlow.repository?.url ?? addProjectCloneFlow.remoteUrl, | |
| 1247 | + icon: remoteProjectSourceIcon(addProjectCloneFlow.source, ITEM_ICON_CLASS), | |
| 1248 | + }; | |
| 1249 | + }, [addProjectCloneFlow]); | |
| 1250 | + | |
| 1251 | + let displayedGroups: CommandPaletteView["groups"] = filteredGroups; | |
| 1252 | + if (addProjectCloneFlow?.step === "repository") { | |
| 1253 | + displayedGroups = []; | |
| 1254 | + } else if (addProjectCloneFlow?.step === "confirm") { | |
| 1255 | + displayedGroups = relativePathNeedsActiveProject ? [] : cloneDestinationBrowseGroups; | |
| 1256 | + } else if (isBrowsing) { | |
| 864 | 1257 | displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups; |
| 865 | 1258 | } |
| 866 | 1259 | |
| 867 | − const inputPlaceholder = getCommandPaletteInputPlaceholder(paletteMode); | |
| 1260 | + const inputPlaceholder = | |
| 1261 | + remoteProjectInputPlaceholder(addProjectCloneFlow) ?? | |
| 1262 | + getCommandPaletteInputPlaceholder(paletteMode); | |
| 868 | 1263 | const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; |
| 869 | 1264 | const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; |
| 870 | 1265 | const canSubmitBrowsePath = isBrowsing && !relativePathNeedsActiveProject; |
| @@ -876,8 +1271,25 @@ function OpenCommandPaletteDialog() { · 5 unmodified lines | ||
| 876 | 1271 | (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null); |
| 877 | 1272 | const useMetaForMod = isMacPlatform(navigator.platform); |
| 878 | 1273 | const submitModifierLabel = useMetaForMod ? "\u2318" : "Ctrl"; |
| 879 | − const submitActionLabel = willCreateProjectPath ? "Create & Add" : "Add"; | |
| 1274 | + const isCloneDestinationStep = addProjectCloneFlow?.step === "confirm"; | |
| 1275 | + const submitActionLabel = isCloneDestinationStep | |
| 1276 | + ? willCreateProjectPath | |
| 1277 | + ? "Create & Clone" | |
| 1278 | + : "Clone" | |
| 1279 | + : willCreateProjectPath | |
| 1280 | + ? "Create & Add" | |
| 1281 | + : "Add"; | |
| 880 | 1282 | const addShortcutLabel = hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"; |
| 1283 | + const remoteProjectButtonLabel = addProjectCloneFlow | |
| 1284 | + ? addProjectCloneFlow.source === "url" | |
| 1285 | + ? "Continue" | |
| 1286 | + : "Lookup" | |
| 1287 | + : null; | |
| 1288 | + const isRemoteProjectPending = isRemoteProjectLookingUp || isRemoteProjectCloning; | |
| 1289 | + const canSubmitRemoteProjectFlow = | |
| 1290 | + addProjectCloneFlow?.step === "repository" && | |
| 1291 | + query.trim().length > 0 && | |
| 1292 | + !isRemoteProjectPending; | |
| 881 | 1293 | const fileManagerName = getLocalFileManagerName(navigator.platform); |
| 882 | 1294 | const canOpenProjectFromFileManager = |
| 883 | 1295 | isBrowsing && |
| @@ -915,6 +1327,12 @@ function OpenCommandPaletteDialog() { · 31 unmodified lines | ||
| 915 | 1327 | } |
| 916 | 1328 | |
| 917 | 1329 | function handleKeyDown(event: KeyboardEvent<HTMLInputElement>): void { |
| 1330 | + if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") { | |
| 1331 | + event.preventDefault(); | |
| 1332 | + void submitAddProjectCloneFlow(); | |
| 1333 | + return; | |
| 1334 | + } | |
| 1335 | + | |
| 918 | 1336 | const shouldSubmitBrowsePath = |
| 919 | 1337 | canSubmitBrowsePath && |
| 920 | 1338 | event.key === "Enter" && |
| @@ -922,7 +1340,11 @@ function OpenCommandPaletteDialog() { · 1 unmodified line | ||
| 922 | 1340 | |
| 923 | 1341 | if (shouldSubmitBrowsePath) { |
| 924 | 1342 | event.preventDefault(); |
| 925 | − void handleAddProject(resolvedAddProjectPath); | |
| 1343 | + if (isCloneDestinationStep) { | |
| 1344 | + void submitAddProjectCloneFlow(resolvedAddProjectPath); | |
| 1345 | + } else { | |
| 1346 | + void handleAddProject(resolvedAddProjectPath); | |
| 1347 | + } | |
| 926 | 1348 | return; |
| 927 | 1349 | } |
| 928 | 1350 | |
| @@ -999,9 +1421,9 @@ function OpenCommandPaletteDialog() { · 70 unmodified lines | ||
| 999 | 1421 | }} |
| 1000 | 1422 | > |
| 1001 | 1423 | <Command |
| 1002 | − key={`${viewStack.length}-${browseGeneration}-${isBrowsing}`} | |
| 1424 | + key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${addProjectCloneFlow?.step ?? "none"}`} | |
| 1003 | 1425 | aria-label="Command palette" |
| 1004 | − autoHighlight={isBrowsing ? false : "always"} | |
| 1426 | + autoHighlight={isBrowsing || isRemoteProjectCloneFlow ? false : "always"} | |
| 1005 | 1427 | mode="none" |
| 1006 | 1428 | onItemHighlighted={(value) => { |
| 1007 | 1429 | setHighlightedItemValue(typeof value === "string" ? value : null); |
| @@ -1011,7 +1433,15 @@ function OpenCommandPaletteDialog() { · 3 unmodified lines | ||
| 1011 | 1433 | > |
| 1012 | 1434 | <div className="relative"> |
| 1013 | 1435 | <CommandInput |
| 1014 | − className={isBrowsing ? (willCreateProjectPath ? "pe-36" : "pe-16") : undefined} | |
| 1436 | + className={ | |
| 1437 | + addProjectCloneFlow?.step === "repository" | |
| 1438 | + ? "pe-32" | |
| 1439 | + : isBrowsing | |
| 1440 | + ? willCreateProjectPath | |
| 1441 | + ? "pe-36" | |
| 1442 | + : "pe-16" | |
| 1443 | + : undefined | |
| 1444 | + } | |
| 1015 | 1445 | placeholder={inputPlaceholder} |
| 1016 | 1446 | wrapperClassName={ |
| 1017 | 1447 | isSubmenu ? "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto" : undefined |
| @@ -1036,7 +1466,28 @@ function OpenCommandPaletteDialog() { · 18 unmodified lines | ||
| 1036 | 1466 | : {})} |
| 1037 | 1467 | onKeyDown={handleKeyDown} |
| 1038 | 1468 | /> |
| 1039 | − {isBrowsing ? ( | |
| 1469 | + {addProjectCloneFlow?.step === "repository" ? ( | |
| 1470 | + <Button | |
| 1471 | + variant="outline" | |
| 1472 | + size="xs" | |
| 1473 | + tabIndex={-1} | |
| 1474 | + className="absolute end-2.5 top-1/2 gap-1.5 pe-1 ps-2 -translate-y-1/2" | |
| 1475 | + aria-label={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`} | |
| 1476 | + disabled={!canSubmitRemoteProjectFlow} | |
| 1477 | + onMouseDown={(event) => { | |
| 1478 | + event.preventDefault(); | |
| 1479 | + }} | |
| 1480 | + onClick={() => { | |
| 1481 | + void submitAddProjectCloneFlow(); | |
| 1482 | + }} | |
| 1483 | + title={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`} | |
| 1484 | + > | |
| 1485 | + <span>{isRemoteProjectPending ? "Working" : remoteProjectButtonLabel}</span> | |
| 1486 | + <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1"> | |
| 1487 | + <Kbd>Enter</Kbd> | |
| 1488 | + </KbdGroup> | |
| 1489 | + </Button> | |
| 1490 | + ) : isBrowsing ? ( | |
| 1040 | 1491 | <Button |
| 1041 | 1492 | variant="outline" |
| 1042 | 1493 | size="xs" |
| @@ -1046,7 +1497,9 @@ function OpenCommandPaletteDialog() { · 3 unmodified lines | ||
| 1046 | 1497 | hasHighlightedBrowseItem ? "gap-1" : "gap-1.5", |
| 1047 | 1498 | )} |
| 1048 | 1499 | aria-label={`${submitActionLabel} (${addShortcutLabel})`} |
| 1049 | − disabled={relativePathNeedsActiveProject} | |
| 1500 | + disabled={ | |
| 1501 | + relativePathNeedsActiveProject || (isCloneDestinationStep && isRemoteProjectPending) | |
| 1502 | + } | |
| 1050 | 1503 | onMouseDown={(event) => { |
| 1051 | 1504 | event.preventDefault(); |
| 1052 | 1505 | }} |
| @@ -1054,11 +1507,17 @@ function OpenCommandPaletteDialog() { · 1 unmodified line | ||
| 1054 | 1507 | if (relativePathNeedsActiveProject) { |
| 1055 | 1508 | return; |
| 1056 | 1509 | } |
| 1057 | − void handleAddProject(resolvedAddProjectPath); | |
| 1510 | + if (isCloneDestinationStep) { | |
| 1511 | + void submitAddProjectCloneFlow(resolvedAddProjectPath); | |
| 1512 | + } else { | |
| 1513 | + void handleAddProject(resolvedAddProjectPath); | |
| 1514 | + } | |
| 1058 | 1515 | }} |
| 1059 | 1516 | title={`${submitActionLabel} (${addShortcutLabel})`} |
| 1060 | 1517 | > |
| 1061 | − <span>{submitActionLabel}</span> | |
| 1518 | + <span> | |
| 1519 | + {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel} | |
| 1520 | + </span> | |
| 1062 | 1521 | <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1"> |
| 1063 | 1522 | <Kbd>{hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"}</Kbd> |
| 1064 | 1523 | </KbdGroup> |
| @@ -1066,19 +1525,47 @@ function OpenCommandPaletteDialog() { · 1 unmodified line | ||
| 1066 | 1525 | ) : null} |
| 1067 | 1526 | </div> |
| 1068 | 1527 | <CommandPanel className="max-h-[min(28rem,70vh)]"> |
| 1528 | + {remoteProjectContext ? ( | |
| 1529 | + <div className="p-2 pb-0"> | |
| 1530 | + <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs"> | |
| 1531 | + Repository | |
| 1532 | + </div> | |
| 1533 | + <div className="flex min-h-8 items-center gap-2 rounded-sm px-2 py-1.5"> | |
| 1534 | + {remoteProjectContext.icon} | |
| 1535 | + <span className="flex min-w-0 flex-1 flex-col"> | |
| 1536 | + <span className="truncate text-foreground text-sm"> | |
| 1537 | + {remoteProjectContext.title} | |
| 1538 | + </span> | |
| 1539 | + <span className="truncate text-muted-foreground/70 text-xs"> | |
| 1540 | + {remoteProjectContext.description} | |
| 1541 | + </span> | |
| 1542 | + </span> | |
| 1543 | + </div> | |
| 1544 | + </div> | |
| 1545 | + ) : null} | |
| 1069 | 1546 | <CommandPaletteResults |
| 1070 | 1547 | groups={displayedGroups} |
| 1071 | 1548 | highlightedItemValue={highlightedItemValue} |
| 1072 | 1549 | isActionsOnly={isActionsOnly} |
| 1073 | 1550 | keybindings={keybindings} |
| 1074 | 1551 | onExecuteItem={executeItem} |
| 1075 | − {...(relativePathNeedsActiveProject | |
| 1076 | − ? { emptyStateMessage: "Relative paths require an active project." } | |
| 1077 | − : willCreateProjectPath | |
| 1078 | − ? { | |
| 1079 | − emptyStateMessage: "Press Enter to create this folder and add it as a project.", | |
| 1080 | − } | |
| 1081 | − : {})} | |
| 1552 | + {...(addProjectCloneFlow?.step === "repository" | |
| 1553 | + ? { | |
| 1554 | + emptyStateMessage: | |
| 1555 | + addProjectCloneFlow.source === "url" | |
| 1556 | + ? "Enter a Git clone URL and press Enter to continue." | |
| 1557 | + : "Enter a repository path and press Enter to look it up.", | |
| 1558 | + } | |
| 1559 | + : addProjectCloneFlow?.step === "confirm" | |
| 1560 | + ? { emptyStateMessage: "Choose a destination path and press Enter to clone." } | |
| 1561 | + : relativePathNeedsActiveProject | |
| 1562 | + ? { emptyStateMessage: "Relative paths require an active project." } | |
| 1563 | + : willCreateProjectPath | |
| 1564 | + ? { | |
| 1565 | + emptyStateMessage: | |
| 1566 | + "Press Enter to create this folder and add it as a project.", | |
| 1567 | + } | |
| 1568 | + : {})} | |
| 1082 | 1569 | /> |
| 1083 | 1570 | </CommandPanel> |
| 1084 | 1571 | <CommandFooter className="gap-3 max-sm:flex-col max-sm:items-start"> |
| @@ -1092,7 +1579,14 @@ function OpenCommandPaletteDialog() { · 7 unmodified lines | ||
| 1092 | 1579 | </Kbd> |
| 1093 | 1580 | <span className={cn("text-muted-foreground/80")}>Navigate</span> |
| 1094 | 1581 | </KbdGroup> |
| 1095 | − {!canSubmitBrowsePath || hasHighlightedBrowseItem ? ( | |
| 1582 | + {addProjectCloneFlow?.step === "repository" ? ( | |
| 1583 | + <KbdGroup className="items-center gap-1.5"> | |
| 1584 | + <Kbd>Enter</Kbd> | |
| 1585 | + <span className={cn("text-muted-foreground/80")}> | |
| 1586 | + {remoteProjectButtonLabel ?? "Continue"} | |
| 1587 | + </span> | |
| 1588 | + </KbdGroup> | |
| 1589 | + ) : !canSubmitBrowsePath || hasHighlightedBrowseItem ? ( | |
| 1096 | 1590 | <KbdGroup className="items-center gap-1.5"> |
| 1097 | 1591 | <Kbd>Enter</Kbd> |
| 1098 | 1592 | <span className={cn("text-muted-foreground/80")}>Select</span> |
| @@ -100,12 +100,14 @@ vi.mock("~/editorPreferences", () => ({ · 99 unmodified lines | ||
| 100 | 100 | vi.mock("~/lib/gitReactQuery", () => ({ |
| 101 | 101 | gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })), |
| 102 | 102 | gitMutationKeys: { |
| 103 | + publishRepository: vi.fn(() => ["publish-repository"]), | |
| 103 | 104 | pull: vi.fn(() => ["pull"]), |
| 104 | 105 | runStackedAction: vi.fn(() => ["run-stacked-action"]), |
| 105 | 106 | }, |
| 106 | 107 | gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })), |
| 107 | 108 | gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })), |
| 108 | 109 | invalidateGitQueries: invalidateGitQueriesSpy, |
| 110 | + sourceControlPublishRepositoryMutationOptions: vi.fn(() => ({ __kind: "publish-repository" })), | |
| 109 | 111 | })); |
| 110 | 112 | |
| 111 | 113 | vi.mock("~/lib/gitStatusState", () => ({ |
| @@ -730,7 +730,7 @@ describe("when: ref has no upstream configured", () => { · 729 unmodified lines | ||
| 730 | 730 | }); |
| 731 | 731 | }); |
| 732 | 732 | |
| 733 | − it("resolveQuickAction disables push-and-pr flows when no origin remote exists", () => { | |
| 733 | + it("resolveQuickAction publishes when no origin remote exists", () => { | |
| 734 | 734 | const quick = resolveQuickAction( |
| 735 | 735 | status({ |
| 736 | 736 | hasUpstream: false, |
| @@ -742,10 +742,9 @@ describe("when: ref has no upstream configured", () => { · 5 unmodified lines | ||
| 742 | 742 | false, |
| 743 | 743 | ); |
| 744 | 744 | assert.deepEqual(quick, { |
| 745 | − kind: "show_hint", | |
| 746 | − label: "Push", | |
| 747 | − hint: 'Add an "origin" remote before pushing or creating a pull request.', | |
| 748 | − disabled: true, | |
| 745 | + kind: "open_publish", | |
| 746 | + label: "Publish repository", | |
| 747 | + disabled: false, | |
| 749 | 748 | }); |
| 750 | 749 | }); |
| 751 | 750 | |
| @@ -779,7 +778,7 @@ describe("when: ref has no upstream configured", () => { · 27 unmodified lines | ||
| 779 | 778 | ]); |
| 780 | 779 | }); |
| 781 | 780 | |
| 782 | − it("buildMenuItems disables push and create PR when no origin remote exists", () => { | |
| 781 | + it("buildMenuItems hides push and create PR when no origin remote exists", () => { | |
| 783 | 782 | const items = buildMenuItems( |
| 784 | 783 | status({ hasUpstream: false, pr: null, aheadCount: 2 }), |
| 785 | 784 | false, |
| @@ -794,22 +793,6 @@ describe("when: ref has no upstream configured", () => { · 8 unmodified lines | ||
| 794 | 793 | kind: "open_dialog", |
| 795 | 794 | dialogAction: "commit", |
| 796 | 795 | }, |
| 797 | − { | |
| 798 | − id: "push", | |
| 799 | − label: "Push", | |
| 800 | − disabled: true, | |
| 801 | − icon: "push", | |
| 802 | − kind: "open_dialog", | |
| 803 | − dialogAction: "push", | |
| 804 | − }, | |
| 805 | − { | |
| 806 | − id: "pr", | |
| 807 | − label: "Create PR", | |
| 808 | − disabled: true, | |
| 809 | − icon: "pr", | |
| 810 | − kind: "open_dialog", | |
| 811 | − dialogAction: "create_pr", | |
| 812 | − }, | |
| 813 | 796 | ]); |
| 814 | 797 | }); |
| 815 | 798 | |
| @@ -26,7 +26,7 @@ export interface GitActionMenuItem { · 25 unmodified lines | ||
| 26 | 26 | export interface GitQuickAction { |
| 27 | 27 | label: string; |
| 28 | 28 | disabled: boolean; |
| 29 | − kind: "run_action" | "run_pull" | "open_pr" | "show_hint"; | |
| 29 | + kind: "run_action" | "run_pull" | "open_pr" | "open_publish" | "show_hint"; | |
| 30 | 30 | action?: GitStackedAction; |
| 31 | 31 | hint?: string; |
| 32 | 32 | } |
| @@ -122,15 +122,21 @@ export function buildMenuItems( · 89 unmodified lines | ||
| 122 | 122 | (gitStatus.hasUpstream || canPushWithoutUpstream); |
| 123 | 123 | const canOpenPr = !isBusy && hasOpenPr; |
| 124 | 124 | |
| 125 | + const commitItem: GitActionMenuItem = { | |
| 126 | + id: "commit", | |
| 127 | + label: "Commit", | |
| 128 | + disabled: !canCommit, | |
| 129 | + icon: "commit", | |
| 130 | + kind: "open_dialog", | |
| 131 | + dialogAction: "commit", | |
| 132 | + }; | |
| 133 | + | |
| 134 | + if (!hasPrimaryRemote) { | |
| 135 | + return [commitItem]; | |
| 136 | + } | |
| 137 | + | |
| 125 | 138 | return [ |
| 126 | − { | |
| 127 | − id: "commit", | |
| 128 | − label: "Commit", | |
| 129 | − disabled: !canCommit, | |
| 130 | − icon: "commit", | |
| 131 | − kind: "open_dialog", | |
| 132 | − dialogAction: "commit", | |
| 133 | − }, | |
| 139 | + commitItem, | |
| 134 | 140 | { |
| 135 | 141 | id: "push", |
| 136 | 142 | label: "Push", |
| @@ -216,10 +222,9 @@ export function resolveQuickAction( · 79 unmodified lines | ||
| 216 | 222 | return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; |
| 217 | 223 | } |
| 218 | 224 | return { |
| 219 | − label: "Push", | |
| 220 | − disabled: true, | |
| 221 | − kind: "show_hint", | |
| 222 | − hint: `Add an "origin" remote before pushing or creating a ${terminology.singular}.`, | |
| 225 | + label: "Publish repository", | |
| 226 | + disabled: false, | |
| 227 | + kind: "open_publish", | |
| 223 | 228 | }; |
| 224 | 229 | } |
| 225 | 230 | if (!isAhead) { |
| @@ -3,11 +3,30 @@ import type { · 2 unmodified lines | ||
| 3 | 3 | GitActionProgressEvent, |
| 4 | 4 | GitRunStackedActionResult, |
| 5 | 5 | GitStackedAction, |
| 6 | + SourceControlCloneProtocol, | |
| 7 | + SourceControlProviderKind, | |
| 8 | + SourceControlPublishRepositoryResult, | |
| 9 | + SourceControlRepositoryVisibility, | |
| 6 | 10 | VcsStatusResult, |
| 7 | 11 | } from "@t3tools/contracts"; |
| 8 | 12 | import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; |
| 13 | +import { Option } from "effect"; | |
| 9 | 14 | import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; |
| 10 | −import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; | |
| 15 | +import { | |
| 16 | + CheckIcon, | |
| 17 | + ChevronDownIcon, | |
| 18 | + CloudUploadIcon, | |
| 19 | + ExternalLinkIcon, | |
| 20 | + GitCommitIcon, | |
| 21 | + InfoIcon, | |
| 22 | + LockIcon, | |
| 23 | + GlobeIcon, | |
| 24 | +} from "lucide-react"; | |
| 25 | +import { Radio as RadioPrimitive } from "@base-ui/react/radio"; | |
| 26 | +import { GitHubIcon, GitLabIcon } from "~/components/Icons"; | |
| 27 | +import { RadioGroup } from "~/components/ui/radio-group"; | |
| 28 | +import { Spinner } from "~/components/ui/spinner"; | |
| 29 | +import { cn } from "~/lib/utils"; | |
| 11 | 30 | import { |
| 12 | 31 | buildGitActionProgressStages, |
| 13 | 32 | buildMenuItems, |
| @@ -33,9 +52,18 @@ import { · 19 unmodified lines | ||
| 33 | 52 | DialogTitle, |
| 34 | 53 | } from "~/components/ui/dialog"; |
| 35 | 54 | import { Group, GroupSeparator } from "~/components/ui/group"; |
| 55 | +import { Input } from "~/components/ui/input"; | |
| 36 | 56 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; |
| 37 | 57 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; |
| 38 | 58 | import { ScrollArea } from "~/components/ui/scroll-area"; |
| 59 | +import { | |
| 60 | + Select, | |
| 61 | + SelectGroup, | |
| 62 | + SelectItem, | |
| 63 | + SelectPopup, | |
| 64 | + SelectTrigger, | |
| 65 | + SelectValue, | |
| 66 | +} from "~/components/ui/select"; | |
| 39 | 67 | import { Textarea } from "~/components/ui/textarea"; |
| 40 | 68 | import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast"; |
| 41 | 69 | import { openInPreferredEditor } from "~/editorPreferences"; |
| @@ -44,8 +72,10 @@ import { · 2 unmodified lines | ||
| 44 | 72 | gitMutationKeys, |
| 45 | 73 | gitPullMutationOptions, |
| 46 | 74 | gitRunStackedActionMutationOptions, |
| 75 | + sourceControlPublishRepositoryMutationOptions, | |
| 47 | 76 | } from "~/lib/gitReactQuery"; |
| 48 | 77 | import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; |
| 78 | +import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState"; | |
| 49 | 79 | import { newCommandId, randomUUID } from "~/lib/utils"; |
| 50 | 80 | import { resolvePathLinkTarget } from "~/terminal-links"; |
| 51 | 81 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; |
| @@ -70,6 +100,8 @@ interface PendingDefaultBranchAction { · 18 unmodified lines | ||
| 70 | 100 | filePaths?: string[]; |
| 71 | 101 | } |
| 72 | 102 | |
| 103 | +type PublishProviderKind = Extract<SourceControlProviderKind, "github" | "gitlab">; | |
| 104 | + | |
| 73 | 105 | type GitActionToastId = ReturnType<typeof toastManager.add>; |
| 74 | 106 | |
| 75 | 107 | interface ActiveGitActionProgress { |
| @@ -211,6 +243,7 @@ function GitQuickActionIcon({ · 135 unmodified lines | ||
| 211 | 243 | }) { |
| 212 | 244 | const iconClassName = "size-3.5"; |
| 213 | 245 | if (quickAction.kind === "open_pr") return <SourceControlIcon className={iconClassName} />; |
| 246 | + if (quickAction.kind === "open_publish") return <CloudUploadIcon className={iconClassName} />; | |
| 214 | 247 | if (quickAction.kind === "run_pull") return <InfoIcon className={iconClassName} />; |
| 215 | 248 | if (quickAction.kind === "run_action") { |
| 216 | 249 | if (quickAction.action === "commit") return <GitCommitIcon className={iconClassName} />; |
| @@ -252,8 +285,35 @@ export default function GitActionsControl({ · 35 unmodified lines | ||
| 252 | 285 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); |
| 253 | 286 | const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set()); |
| 254 | 287 | const [isEditingFiles, setIsEditingFiles] = useState(false); |
| 288 | + const [isPublishDialogOpen, setIsPublishDialogOpen] = useState(false); | |
| 289 | + const [publishProvider, setPublishProvider] = useState<PublishProviderKind>("github"); | |
| 290 | + const [publishRepository, setPublishRepository] = useState(""); | |
| 291 | + const [publishVisibility, setPublishVisibility] = | |
| 292 | + useState<SourceControlRepositoryVisibility>("private"); | |
| 293 | + const [publishRemoteName, setPublishRemoteName] = useState("origin"); | |
| 294 | + const [publishProtocol, setPublishProtocol] = useState<SourceControlCloneProtocol>("ssh"); | |
| 295 | + const [publishWizardStep, setPublishWizardStep] = useState(0); | |
| 296 | + const [publishAdvancedOpen, setPublishAdvancedOpen] = useState(false); | |
| 297 | + const [publishError, setPublishError] = useState<string | null>(null); | |
| 298 | + const [publishResult, setPublishResult] = useState<SourceControlPublishRepositoryResult | null>( | |
| 299 | + null, | |
| 300 | + ); | |
| 301 | + const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false); | |
| 255 | 302 | const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = |
| 256 | 303 | useState<PendingDefaultBranchAction | null>(null); |
| 304 | + const sourceControlDiscovery = useSourceControlDiscovery(); | |
| 305 | + const publishAccountByProvider = useMemo(() => { | |
| 306 | + const accounts: Record<PublishProviderKind, string | null> = { github: null, gitlab: null }; | |
| 307 | + for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) { | |
| 308 | + if (provider.kind === "github" || provider.kind === "gitlab") { | |
| 309 | + accounts[provider.kind] = Option.getOrNull(provider.auth.account); | |
| 310 | + } | |
| 311 | + } | |
| 312 | + return accounts; | |
| 313 | + }, [sourceControlDiscovery.data]); | |
| 314 | + const publishRepositoryPrefill = publishAccountByProvider[publishProvider] | |
| 315 | + ? `${publishAccountByProvider[publishProvider]}/` | |
| 316 | + : ""; | |
| 257 | 317 | const activeGitActionProgressRef = useRef<ActiveGitActionProgress | null>(null); |
| 258 | 318 | let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise<void>; |
| 259 | 319 | |
| @@ -365,6 +425,13 @@ export default function GitActionsControl({ · 105 unmodified lines | ||
| 365 | 425 | const pullMutation = useMutation( |
| 366 | 426 | gitPullMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), |
| 367 | 427 | ); |
| 428 | + const publishRepositoryMutation = useMutation( | |
| 429 | + sourceControlPublishRepositoryMutationOptions({ | |
| 430 | + environmentId: activeEnvironmentId, | |
| 431 | + cwd: gitCwd, | |
| 432 | + queryClient, | |
| 433 | + }), | |
| 434 | + ); | |
| 368 | 435 | |
| 369 | 436 | const isRunStackedActionRunning = |
| 370 | 437 | useIsMutating({ |
| @@ -372,7 +439,11 @@ export default function GitActionsControl({ · 1 unmodified line | ||
| 372 | 439 | }) > 0; |
| 373 | 440 | const isPullRunning = |
| 374 | 441 | useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0; |
| 375 | − const isGitActionRunning = isRunStackedActionRunning || isPullRunning; | |
| 442 | + const isPublishRunning = | |
| 443 | + useIsMutating({ | |
| 444 | + mutationKey: gitMutationKeys.publishRepository(activeEnvironmentId, gitCwd), | |
| 445 | + }) > 0; | |
| 446 | + const isGitActionRunning = isRunStackedActionRunning || isPullRunning || isPublishRunning; | |
| 376 | 447 | const isSelectingWorktreeBase = |
| 377 | 448 | !activeServerThread && |
| 378 | 449 | activeDraftThread?.envMode === "worktree" && |
| @@ -439,6 +510,13 @@ export default function GitActionsControl({ · 60 unmodified lines | ||
| 439 | 510 | }; |
| 440 | 511 | }, [updateActiveProgressToast]); |
| 441 | 512 | |
| 513 | + useEffect(() => { | |
| 514 | + if (!isPublishDialogOpen || hasUserEditedPublishRepository) { | |
| 515 | + return; | |
| 516 | + } | |
| 517 | + setPublishRepository(publishRepositoryPrefill); | |
| 518 | + }, [isPublishDialogOpen, hasUserEditedPublishRepository, publishRepositoryPrefill]); | |
| 519 | + | |
| 442 | 520 | useEffect(() => { |
| 443 | 521 | if (gitCwd === null) { |
| 444 | 522 | return; |
| @@ -785,6 +863,10 @@ export default function GitActionsControl({ · 340 unmodified lines | ||
| 785 | 863 | void openExistingPr(); |
| 786 | 864 | return; |
| 787 | 865 | } |
| 866 | + if (quickAction.kind === "open_publish") { | |
| 867 | + setIsPublishDialogOpen(true); | |
| 868 | + return; | |
| 869 | + } | |
| 788 | 870 | if (quickAction.kind === "run_pull") { |
| 789 | 871 | const promise = pullMutation.mutateAsync(); |
| 790 | 872 | void toastManager.promise< |
| @@ -882,6 +964,47 @@ export default function GitActionsControl({ · 91 unmodified lines | ||
| 882 | 964 | [gitCwd, threadToastData], |
| 883 | 965 | ); |
| 884 | 966 | |
| 967 | + const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote; | |
| 968 | + const canSubmitPublishRepository = (() => { | |
| 969 | + if (publishRepositoryMutation.isPending) return false; | |
| 970 | + const repositoryParts = publishRepository.trim().split("/"); | |
| 971 | + const owner = repositoryParts[0]?.trim() ?? ""; | |
| 972 | + const rest = repositoryParts.slice(1); | |
| 973 | + const name = rest.join("/").trim(); | |
| 974 | + return owner.length > 0 && name.length > 0; | |
| 975 | + })(); | |
| 976 | + | |
| 977 | + const publishHost = publishProvider === "github" ? "github.com" : "gitlab.com"; | |
| 978 | + const publishPathPlaceholder = publishProvider === "github" ? "owner/repo" : "group/project"; | |
| 979 | + const publishProviderLabel = publishProvider === "github" ? "GitHub" : "GitLab"; | |
| 980 | + | |
| 981 | + const submitPublishRepository = () => { | |
| 982 | + if (!canSubmitPublishRepository) { | |
| 983 | + return; | |
| 984 | + } | |
| 985 | + | |
| 986 | + setPublishError(null); | |
| 987 | + | |
| 988 | + void publishRepositoryMutation | |
| 989 | + .mutateAsync({ | |
| 990 | + provider: publishProvider, | |
| 991 | + repository: publishRepository.trim(), | |
| 992 | + visibility: publishVisibility, | |
| 993 | + remoteName: publishRemoteName.trim() || "origin", | |
| 994 | + protocol: publishProtocol, | |
| 995 | + }) | |
| 996 | + .then((result) => { | |
| 997 | + setPublishResult(result); | |
| 998 | + setPublishWizardStep(2); | |
| 999 | + void refreshGitStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch( | |
| 1000 | + () => undefined, | |
| 1001 | + ); | |
| 1002 | + }) | |
| 1003 | + .catch((err: unknown) => { | |
| 1004 | + setPublishError(err instanceof Error ? err.message : "An error occurred."); | |
| 1005 | + }); | |
| 1006 | + }; | |
| 1007 | + | |
| 885 | 1008 | if (!gitCwd) return null; |
| 886 | 1009 | |
| 887 | 1010 | return ( |
| @@ -996,6 +1119,17 @@ export default function GitActionsControl({ · 108 unmodified lines | ||
| 996 | 1119 | </MenuItem> |
| 997 | 1120 | ); |
| 998 | 1121 | })} |
| 1122 | + {canPublishRepository ? ( | |
| 1123 | + <MenuItem | |
| 1124 | + disabled={isGitActionRunning} | |
| 1125 | + onClick={() => { | |
| 1126 | + setIsPublishDialogOpen(true); | |
| 1127 | + }} | |
| 1128 | + > | |
| 1129 | + <CloudUploadIcon /> | |
| 1130 | + Publish repository... | |
| 1131 | + </MenuItem> | |
| 1132 | + ) : null} | |
| 999 | 1133 | {gitStatusForActions?.refName === null && ( |
| 1000 | 1134 | <p className="px-2 py-1.5 text-xs text-warning"> |
| 1001 | 1135 | Detached HEAD: create and checkout a refName to enable push and pull request |
| @@ -1188,6 +1322,420 @@ export default function GitActionsControl({ · 186 unmodified lines | ||
| 1188 | 1322 | </DialogPopup> |
| 1189 | 1323 | </Dialog> |
| 1190 | 1324 | |
| 1325 | + <Dialog | |
| 1326 | + open={isPublishDialogOpen} | |
| 1327 | + onOpenChange={(open) => { | |
| 1328 | + setIsPublishDialogOpen(open); | |
| 1329 | + if (!open) { | |
| 1330 | + setPublishRemoteName("origin"); | |
| 1331 | + setPublishRepository(""); | |
| 1332 | + setHasUserEditedPublishRepository(false); | |
| 1333 | + setPublishWizardStep(0); | |
| 1334 | + setPublishAdvancedOpen(false); | |
| 1335 | + setPublishError(null); | |
| 1336 | + setPublishResult(null); | |
| 1337 | + } | |
| 1338 | + }} | |
| 1339 | + > | |
| 1340 | + <DialogPopup className="max-w-lg"> | |
| 1341 | + <DialogHeader> | |
| 1342 | + <DialogTitle>Publish repository</DialogTitle> | |
| 1343 | + <DialogDescription> | |
| 1344 | + Pick where to host it, then point us at a repo to push to. | |
| 1345 | + </DialogDescription> | |
| 1346 | + <div className="mt-1 grid grid-cols-3 gap-2"> | |
| 1347 | + {(["Provider", "Repository", "Summary"] as const).map((label, index) => { | |
| 1348 | + const summary = | |
| 1349 | + index === 0 && publishWizardStep > 0 | |
| 1350 | + ? publishProviderLabel | |
| 1351 | + : index === 1 && publishWizardStep > 1 && publishResult | |
| 1352 | + ? publishResult.repository.nameWithOwner | |
| 1353 | + : null; | |
| 1354 | + const isComplete = index < publishWizardStep; | |
| 1355 | + const isDoneStep = index === 2; | |
| 1356 | + // The summary step is only reachable via a successful submit; | |
| 1357 | + // disallow user-driven navigation to or from it. | |
| 1358 | + const isClickable = | |
| 1359 | + publishWizardStep !== 2 && !isDoneStep && index <= publishWizardStep; | |
| 1360 | + return ( | |
| 1361 | + <button | |
| 1362 | + key={label} | |
| 1363 | + type="button" | |
| 1364 | + onClick={isClickable ? () => setPublishWizardStep(index) : undefined} | |
| 1365 | + disabled={!isClickable} | |
| 1366 | + className={cn( | |
| 1367 | + "grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 rounded-lg border px-3 py-2 text-left transition", | |
| 1368 | + index === publishWizardStep | |
| 1369 | + ? "border-primary bg-primary/10 ring-1 ring-primary/25" | |
| 1370 | + : isComplete | |
| 1371 | + ? "border-border bg-background" | |
| 1372 | + : "border-border bg-muted/40", | |
| 1373 | + !isClickable && "cursor-default", | |
| 1374 | + )} | |
| 1375 | + > | |
| 1376 | + <span | |
| 1377 | + aria-hidden | |
| 1378 | + className={cn( | |
| 1379 | + "row-span-2 mt-0.5 grid size-4 place-items-center rounded-full border", | |
| 1380 | + isComplete | |
| 1381 | + ? "border-primary bg-primary text-primary-foreground" | |
| 1382 | + : index === publishWizardStep | |
| 1383 | + ? "border-primary bg-background" | |
| 1384 | + : "border-muted-foreground/35 bg-background", | |
| 1385 | + )} | |
| 1386 | + > | |
| 1387 | + {isComplete ? <CheckIcon className="size-3" /> : null} | |
| 1388 | + </span> | |
| 1389 | + <span className="text-[10px] font-medium uppercase text-muted-foreground"> | |
| 1390 | + Step {index + 1} | |
| 1391 | + </span> | |
| 1392 | + <span className="truncate text-xs font-semibold text-foreground"> | |
| 1393 | + {label} | |
| 1394 | + {summary ? `: ${summary}` : ""} | |
| 1395 | + </span> | |
| 1396 | + </button> | |
| 1397 | + ); | |
| 1398 | + })} | |
| 1399 | + </div> | |
| 1400 | + </DialogHeader> | |
| 1401 | + | |
| 1402 | + {/* Step 1: Provider */} | |
| 1403 | + {publishWizardStep === 0 && ( | |
| 1404 | + <DialogPanel className="space-y-4"> | |
| 1405 | + <div className="space-y-2"> | |
| 1406 | + <span | |
| 1407 | + id="publish-provider-cards-label" | |
| 1408 | + className="text-xs font-medium text-foreground" | |
| 1409 | + > | |
| 1410 | + Provider | |
| 1411 | + </span> | |
| 1412 | + <RadioGroup | |
| 1413 | + value={publishProvider} | |
| 1414 | + onValueChange={(value) => setPublishProvider(value as PublishProviderKind)} | |
| 1415 | + aria-labelledby="publish-provider-cards-label" | |
| 1416 | + className="grid grid-cols-2 gap-2.5" | |
| 1417 | + > | |
| 1418 | + {[ | |
| 1419 | + { | |
| 1420 | + value: "github" as const, | |
| 1421 | + label: "GitHub", | |
| 1422 | + description: "github.com", | |
| 1423 | + Icon: GitHubIcon, | |
| 1424 | + }, | |
| 1425 | + { | |
| 1426 | + value: "gitlab" as const, | |
| 1427 | + label: "GitLab", | |
| 1428 | + description: "gitlab.com", | |
| 1429 | + Icon: GitLabIcon, | |
| 1430 | + }, | |
| 1431 | + ].map((option) => { | |
| 1432 | + const isSelected = publishProvider === option.value; | |
| 1433 | + return ( | |
| 1434 | + <RadioPrimitive.Root | |
| 1435 | + key={option.value} | |
| 1436 | + value={option.value} | |
| 1437 | + className={cn( | |
| 1438 | + "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-3 text-left outline-none transition-[background-color,border-color,box-shadow]", | |
| 1439 | + "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", | |
| 1440 | + isSelected | |
| 1441 | + ? "border-primary bg-background shadow-sm ring-2 ring-primary/35" | |
| 1442 | + : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50", | |
| 1443 | + )} | |
| 1444 | + > | |
| 1445 | + <option.Icon className="size-5 shrink-0" aria-hidden /> | |
| 1446 | + <span className="min-w-0 flex-1"> | |
| 1447 | + <span className="block text-sm font-medium text-foreground"> | |
| 1448 | + {option.label} | |
| 1449 | + </span> | |
| 1450 | + <span className="block text-xs text-muted-foreground"> | |
| 1451 | + {option.description} | |
| 1452 | + </span> | |
| 1453 | + </span> | |
| 1454 | + </RadioPrimitive.Root> | |
| 1455 | + ); | |
| 1456 | + })} | |
| 1457 | + </RadioGroup> | |
| 1458 | + </div> | |
| 1459 | + </DialogPanel> | |
| 1460 | + )} | |
| 1461 | + {publishWizardStep === 1 && ( | |
| 1462 | + <DialogPanel className="space-y-5"> | |
| 1463 | + <div className="space-y-2"> | |
| 1464 | + <label | |
| 1465 | + htmlFor="publish-repository-path" | |
| 1466 | + className="text-xs font-medium text-foreground" | |
| 1467 | + > | |
| 1468 | + Repository | |
| 1469 | + </label> | |
| 1470 | + <div className="flex items-stretch overflow-hidden rounded-md border border-input bg-background focus-within:outline-2 focus-within:-outline-offset-1 focus-within:outline-ring"> | |
| 1471 | + <span className="flex shrink-0 items-center gap-1.5 border-r border-input bg-muted/50 px-2.5 font-mono text-xs text-muted-foreground"> | |
| 1472 | + {publishProvider === "github" ? ( | |
| 1473 | + <GitHubIcon className="size-3.5" /> | |
| 1474 | + ) : ( | |
| 1475 | + <GitLabIcon className="size-3.5" /> | |
| 1476 | + )} | |
| 1477 | + {publishHost}/ | |
| 1478 | + </span> | |
| 1479 | + <input | |
| 1480 | + id="publish-repository-path" | |
| 1481 | + name="publish-repository-path" | |
| 1482 | + value={publishRepository} | |
| 1483 | + onChange={(event) => { | |
| 1484 | + setPublishRepository(event.target.value); | |
| 1485 | + setHasUserEditedPublishRepository(true); | |
| 1486 | + }} | |
| 1487 | + onKeyDown={(event) => { | |
| 1488 | + if (event.key === "Enter") { | |
| 1489 | + event.preventDefault(); | |
| 1490 | + submitPublishRepository(); | |
| 1491 | + } | |
| 1492 | + }} | |
| 1493 | + placeholder={publishPathPlaceholder} | |
| 1494 | + disabled={publishRepositoryMutation.isPending} | |
| 1495 | + className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none" | |
| 1496 | + /> | |
| 1497 | + </div> | |
| 1498 | + </div> | |
| 1499 | + <div className="space-y-2"> | |
| 1500 | + <span | |
| 1501 | + id="publish-visibility-cards-label" | |
| 1502 | + className="text-xs font-medium text-foreground" | |
| 1503 | + > | |
| 1504 | + Visibility | |
| 1505 | + </span> | |
| 1506 | + <RadioGroup | |
| 1507 | + value={publishVisibility} | |
| 1508 | + onValueChange={(value) => | |
| 1509 | + setPublishVisibility(value as SourceControlRepositoryVisibility) | |
| 1510 | + } | |
| 1511 | + aria-labelledby="publish-visibility-cards-label" | |
| 1512 | + disabled={publishRepositoryMutation.isPending} | |
| 1513 | + className="grid grid-cols-2 gap-2.5" | |
| 1514 | + > | |
| 1515 | + {[ | |
| 1516 | + { | |
| 1517 | + value: "private" as const, | |
| 1518 | + label: "Private", | |
| 1519 | + description: "Only invited people", | |
| 1520 | + Icon: LockIcon, | |
| 1521 | + }, | |
| 1522 | + { | |
| 1523 | + value: "public" as const, | |
| 1524 | + label: "Public", | |
| 1525 | + description: "Anyone on the web", | |
| 1526 | + Icon: GlobeIcon, | |
| 1527 | + }, | |
| 1528 | + ].map((option) => { | |
| 1529 | + const isSelected = publishVisibility === option.value; | |
| 1530 | + return ( | |
| 1531 | + <RadioPrimitive.Root | |
| 1532 | + key={option.value} | |
| 1533 | + value={option.value} | |
| 1534 | + className={cn( | |
| 1535 | + "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]", | |
| 1536 | + "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", | |
| 1537 | + isSelected | |
| 1538 | + ? "border-primary bg-background shadow-sm ring-2 ring-primary/35" | |
| 1539 | + : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50", | |
| 1540 | + )} | |
| 1541 | + > | |
| 1542 | + <option.Icon | |
| 1543 | + className="size-4 shrink-0 text-muted-foreground" | |
| 1544 | + aria-hidden | |
| 1545 | + /> | |
| 1546 | + <span className="min-w-0 flex-1"> | |
| 1547 | + <span className="block text-sm font-medium text-foreground"> | |
| 1548 | + {option.label} | |
| 1549 | + </span> | |
| 1550 | + <span className="block text-xs text-muted-foreground"> | |
| 1551 | + {option.description} | |
| 1552 | + </span> | |
| 1553 | + </span> | |
| 1554 | + </RadioPrimitive.Root> | |
| 1555 | + ); | |
| 1556 | + })} | |
| 1557 | + </RadioGroup> | |
| 1558 | + </div> | |
| 1559 | + <div> | |
| 1560 | + <button | |
| 1561 | + type="button" | |
| 1562 | + onClick={() => setPublishAdvancedOpen((prev) => !prev)} | |
| 1563 | + aria-expanded={publishAdvancedOpen} | |
| 1564 | + className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground" | |
| 1565 | + > | |
| 1566 | + <ChevronDownIcon | |
| 1567 | + className={cn( | |
| 1568 | + "size-3.5 transition-transform", | |
| 1569 | + publishAdvancedOpen ? "" : "-rotate-90", | |
| 1570 | + )} | |
| 1571 | + /> | |
| 1572 | + Advanced | |
| 1573 | + </button> | |
| 1574 | + {publishAdvancedOpen && ( | |
| 1575 | + <div className="mt-3 grid gap-3 sm:grid-cols-2"> | |
| 1576 | + <label className="space-y-1.5" htmlFor="publish-remote-name"> | |
| 1577 | + <span className="text-xs font-medium text-foreground">Remote</span> | |
| 1578 | + <Input | |
| 1579 | + id="publish-remote-name" | |
| 1580 | + value={publishRemoteName} | |
| 1581 | + onChange={(event) => setPublishRemoteName(event.target.value)} | |
| 1582 | + placeholder="origin" | |
| 1583 | + disabled={publishRepositoryMutation.isPending} | |
| 1584 | + /> | |
| 1585 | + </label> | |
| 1586 | + <div className="space-y-1.5"> | |
| 1587 | + <span | |
| 1588 | + id="publish-protocol-label" | |
| 1589 | + className="text-xs font-medium text-foreground" | |
| 1590 | + > | |
| 1591 | + Protocol | |
| 1592 | + </span> | |
| 1593 | + <RadioGroup | |
| 1594 | + value={publishProtocol} | |
| 1595 | + onValueChange={(value) => | |
| 1596 | + setPublishProtocol(value as SourceControlCloneProtocol) | |
| 1597 | + } | |
| 1598 | + aria-labelledby="publish-protocol-label" | |
| 1599 | + disabled={publishRepositoryMutation.isPending} | |
| 1600 | + className="grid grid-cols-2 gap-2" | |
| 1601 | + > | |
| 1602 | + {(["ssh", "https"] as const).map((value) => { | |
| 1603 | + const isSelected = publishProtocol === value; | |
| 1604 | + return ( | |
| 1605 | + <RadioPrimitive.Root | |
| 1606 | + key={value} | |
| 1607 | + value={value} | |
| 1608 | + className={cn( | |
| 1609 | + "rounded-md border px-3 py-1.5 text-center text-sm font-medium outline-none transition", | |
| 1610 | + "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", | |
| 1611 | + isSelected | |
| 1612 | + ? "border-primary bg-background ring-2 ring-primary/35 text-foreground" | |
| 1613 | + : "border-border bg-background text-muted-foreground hover:border-foreground/20 hover:text-foreground", | |
| 1614 | + )} | |
| 1615 | + > | |
| 1616 | + {value === "ssh" ? "SSH" : "HTTPS"} | |
| 1617 | + </RadioPrimitive.Root> | |
| 1618 | + ); | |
| 1619 | + })} | |
| 1620 | + </RadioGroup> | |
| 1621 | + </div> | |
| 1622 | + </div> | |
| 1623 | + )} | |
| 1624 | + </div> | |
| 1625 | + {publishRepositoryMutation.isPending && ( | |
| 1626 | + <div | |
| 1627 | + role="status" | |
| 1628 | + aria-live="polite" | |
| 1629 | + className="flex items-center gap-2 rounded-md border border-input bg-muted/40 px-3 py-2 text-xs text-muted-foreground" | |
| 1630 | + > | |
| 1631 | + <Spinner className="size-3.5" aria-hidden /> | |
| 1632 | + Publishing repository to {publishProviderLabel}... | |
| 1633 | + </div> | |
| 1634 | + )} | |
| 1635 | + {publishError && !publishRepositoryMutation.isPending && ( | |
| 1636 | + <div | |
| 1637 | + role="alert" | |
| 1638 | + className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive" | |
| 1639 | + > | |
| 1640 | + <p className="font-medium">Publish failed</p> | |
| 1641 | + <p className="mt-0.5 text-destructive/90">{publishError}</p> | |
| 1642 | + </div> | |
| 1643 | + )} | |
| 1644 | + </DialogPanel> | |
| 1645 | + )} | |
| 1646 | + {publishWizardStep === 2 && publishResult && ( | |
| 1647 | + <DialogPanel className="space-y-4"> | |
| 1648 | + <div className="flex flex-col items-center gap-2 py-1 text-center"> | |
| 1649 | + <span className="grid size-8 place-items-center rounded-full bg-success/15 text-success"> | |
| 1650 | + <CheckIcon className="size-4" aria-hidden /> | |
| 1651 | + </span> | |
| 1652 | + <h3 className="text-sm font-semibold text-foreground"> | |
| 1653 | + {publishResult.status === "pushed" | |
| 1654 | + ? "Repository published" | |
| 1655 | + : "Repository created"} | |
| 1656 | + </h3> | |
| 1657 | + <p className="max-w-xs text-pretty text-xs text-muted-foreground"> | |
| 1658 | + {publishResult.status === "pushed" | |
| 1659 | + ? `${publishResult.branch} is now live on ${publishProviderLabel}.` | |
| 1660 | + : `Remote "${publishResult.remoteName}" is set up. Make a commit and push it to share your code.`} | |
| 1661 | + </p> | |
| 1662 | + </div> | |
| 1663 | + <div className="flex items-center gap-2 rounded-lg border border-input bg-muted/40 px-3 py-2"> | |
| 1664 | + {publishProvider === "github" ? ( | |
| 1665 | + <GitHubIcon className="size-3.5 shrink-0 text-muted-foreground" /> | |
| 1666 | + ) : ( | |
| 1667 | + <GitLabIcon className="size-3.5 shrink-0 text-muted-foreground" /> | |
| 1668 | + )} | |
| 1669 | + <span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground"> | |
| 1670 | + {publishResult.repository.nameWithOwner} | |
| 1671 | + </span> | |
| 1672 | + </div> | |
| 1673 | + <Button | |
| 1674 | + variant="outline" | |
| 1675 | + size="sm" | |
| 1676 | + className="w-full" | |
| 1677 | + onClick={() => { | |
| 1678 | + const api = readLocalApi(); | |
| 1679 | + if (!api) return; | |
| 1680 | + void api.shell.openExternal(publishResult.repository.url); | |
| 1681 | + }} | |
| 1682 | + > | |
| 1683 | + <ExternalLinkIcon className="size-3.5" aria-hidden /> | |
| 1684 | + Open on {publishProviderLabel} | |
| 1685 | + </Button> | |
| 1686 | + </DialogPanel> | |
| 1687 | + )} | |
| 1688 | + | |
| 1689 | + <DialogFooter> | |
| 1690 | + {publishWizardStep === 2 ? ( | |
| 1691 | + <Button size="sm" onClick={() => setIsPublishDialogOpen(false)}> | |
| 1692 | + Done | |
| 1693 | + </Button> | |
| 1694 | + ) : ( | |
| 1695 | + <> | |
| 1696 | + <Button | |
| 1697 | + variant="outline" | |
| 1698 | + size="sm" | |
| 1699 | + disabled={publishRepositoryMutation.isPending} | |
| 1700 | + onClick={() => { | |
| 1701 | + if (publishWizardStep === 0) { | |
| 1702 | + setIsPublishDialogOpen(false); | |
| 1703 | + return; | |
| 1704 | + } | |
| 1705 | + setPublishWizardStep((step) => Math.max(0, step - 1)); | |
| 1706 | + }} | |
| 1707 | + > | |
| 1708 | + {publishWizardStep === 0 ? "Cancel" : "Back"} | |
| 1709 | + </Button> | |
| 1710 | + {publishWizardStep < 1 ? ( | |
| 1711 | + <Button | |
| 1712 | + size="sm" | |
| 1713 | + onClick={() => setPublishWizardStep((step) => Math.min(1, step + 1))} | |
| 1714 | + > | |
| 1715 | + Next | |
| 1716 | + </Button> | |
| 1717 | + ) : ( | |
| 1718 | + <Button | |
| 1719 | + size="sm" | |
| 1720 | + disabled={!canSubmitPublishRepository} | |
| 1721 | + onClick={submitPublishRepository} | |
| 1722 | + > | |
| 1723 | + {publishRepositoryMutation.isPending ? ( | |
| 1724 | + <> | |
| 1725 | + <Spinner className="size-3.5" aria-hidden /> | |
| 1726 | + Publishing... | |
| 1727 | + </> | |
| 1728 | + ) : ( | |
| 1729 | + "Publish" | |
| 1730 | + )} | |
| 1731 | + </Button> | |
| 1732 | + )} | |
| 1733 | + </> | |
| 1734 | + )} | |
| 1735 | + </DialogFooter> | |
| 1736 | + </DialogPopup> | |
| 1737 | + </Dialog> | |
| 1738 | + | |
| 1191 | 1739 | <Dialog |
| 1192 | 1740 | open={pendingDefaultBranchAction !== null} |
| 1193 | 1741 | onOpenChange={(open) => { |
| @@ -57,6 +57,8 @@ const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = { · 56 unmodified lines | ||
| 57 | 57 | jj: JujutsuIcon, |
| 58 | 58 | }; |
| 59 | 59 | |
| 60 | +const SOURCE_CONTROL_SKELETON_ROWS = ["primary", "secondary"] as const; | |
| 61 | + | |
| 60 | 62 | function optionLabel(value: Option.Option<string>): string | null { |
| 61 | 63 | return Option.getOrNull(value); |
| 62 | 64 | } |
| @@ -157,6 +159,11 @@ function itemSummary({ · 94 unmodified lines | ||
| 157 | 159 | </> |
| 158 | 160 | ); |
| 159 | 161 | } |
| 162 | + | |
| 163 | + if (!item.executable) { | |
| 164 | + return <span>{item.installHint}</span>; | |
| 165 | + } | |
| 166 | + | |
| 160 | 167 | if (auth.status === "unauthenticated") { |
| 161 | 168 | return <span>Sign in with the {item.executable} CLI to enable pull request actions.</span>; |
| 162 | 169 | } |
| @@ -232,8 +239,8 @@ function SourceControlSectionSkeleton({ · 69 unmodified lines | ||
| 232 | 239 | }) { |
| 233 | 240 | return ( |
| 234 | 241 | <SettingsSection title={title} headerAction={headerAction}> |
| 235 | − {Array.from({ length: 2 }, (_, index) => ( | |
| 236 | − <div key={index} className="border-t border-border/60 px-4 py-3.5 first:border-t-0 sm:px-5"> | |
| 242 | + {SOURCE_CONTROL_SKELETON_ROWS.map((row) => ( | |
| 243 | + <div key={row} className="border-t border-border/60 px-4 py-3.5 first:border-t-0 sm:px-5"> | |
| 237 | 244 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
| 238 | 245 | <div className="min-w-0 flex-1 space-y-2"> |
| 239 | 246 | <div className="flex items-center gap-2"> |
| @@ -23,6 +23,11 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { · 22 unmodified lines | ||
| 23 | 23 | filesystem: { |
| 24 | 24 | browse: rpcClient.filesystem.browse, |
| 25 | 25 | }, |
| 26 | + sourceControl: { | |
| 27 | + lookupRepository: rpcClient.sourceControl.lookupRepository, | |
| 28 | + cloneRepository: rpcClient.sourceControl.cloneRepository, | |
| 29 | + publishRepository: rpcClient.sourceControl.publishRepository, | |
| 30 | + }, | |
| 26 | 31 | vcs: { |
| 27 | 32 | pull: rpcClient.vcs.pull, |
| 28 | 33 | refreshStatus: rpcClient.vcs.refreshStatus, |
| @@ -2,6 +2,7 @@ import { · 1 unmodified line | ||
| 2 | 2 | type EnvironmentId, |
| 3 | 3 | type GitActionProgressEvent, |
| 4 | 4 | type GitStackedAction, |
| 5 | + type SourceControlPublishRepositoryInput, | |
| 5 | 6 | type ThreadId, |
| 6 | 7 | } from "@t3tools/contracts"; |
| 7 | 8 | import { |
| @@ -36,6 +37,8 @@ export const gitMutationKeys = { · 28 unmodified lines | ||
| 36 | 37 | ["git", "mutation", "pull", environmentId ?? null, cwd] as const, |
| 37 | 38 | preparePullRequestThread: (environmentId: EnvironmentId | null, cwd: string | null) => |
| 38 | 39 | ["git", "mutation", "prepare-pull-request-thread", environmentId ?? null, cwd] as const, |
| 40 | + publishRepository: (environmentId: EnvironmentId | null, cwd: string | null) => | |
| 41 | + ["git", "mutation", "publish-repository", environmentId ?? null, cwd] as const, | |
| 39 | 42 | }; |
| 40 | 43 | |
| 41 | 44 | export function invalidateGitQueries( |
| @@ -228,6 +231,26 @@ export function gitPullMutationOptions(input: { · 186 unmodified lines | ||
| 228 | 231 | }); |
| 229 | 232 | } |
| 230 | 233 | |
| 234 | +export function sourceControlPublishRepositoryMutationOptions(input: { | |
| 235 | + environmentId: EnvironmentId | null; | |
| 236 | + cwd: string | null; | |
| 237 | + queryClient: QueryClient; | |
| 238 | +}) { | |
| 239 | + return mutationOptions({ | |
| 240 | + mutationKey: gitMutationKeys.publishRepository(input.environmentId, input.cwd), | |
| 241 | + mutationFn: async (args: Omit<SourceControlPublishRepositoryInput, "cwd">) => { | |
| 242 | + if (!input.cwd || !input.environmentId) { | |
| 243 | + throw new Error("Repository publishing is unavailable."); | |
| 244 | + } | |
| 245 | + const api = ensureEnvironmentApi(input.environmentId); | |
| 246 | + return api.sourceControl.publishRepository({ cwd: input.cwd, ...args }); | |
| 247 | + }, | |
| 248 | + onSuccess: async () => { | |
| 249 | + await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); | |
| 250 | + }, | |
| 251 | + }); | |
| 252 | +} | |
| 253 | + | |
| 231 | 254 | /** |
| 232 | 255 | * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. |
| 233 | 256 | */ |
| @@ -56,6 +56,11 @@ const rpcClientMock = { · 55 unmodified lines | ||
| 56 | 56 | filesystem: { |
| 57 | 57 | browse: vi.fn(), |
| 58 | 58 | }, |
| 59 | + sourceControl: { | |
| 60 | + lookupRepository: vi.fn(), | |
| 61 | + cloneRepository: vi.fn(), | |
| 62 | + publishRepository: vi.fn(), | |
| 63 | + }, | |
| 59 | 64 | shell: { |
| 60 | 65 | openInEditor: vi.fn(), |
| 61 | 66 | }, |
| @@ -71,6 +71,11 @@ export interface WsRpcClient { · 70 unmodified lines | ||
| 71 | 71 | readonly filesystem: { |
| 72 | 72 | readonly browse: RpcUnaryMethod<typeof WS_METHODS.filesystemBrowse>; |
| 73 | 73 | }; |
| 74 | + readonly sourceControl: { | |
| 75 | + readonly lookupRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlLookupRepository>; | |
| 76 | + readonly cloneRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlCloneRepository>; | |
| 77 | + readonly publishRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlPublishRepository>; | |
| 78 | + }; | |
| 74 | 79 | readonly shell: { |
| 75 | 80 | readonly openInEditor: (input: { |
| 76 | 81 | readonly cwd: Parameters<LocalApi["shell"]["openInEditor"]>[0]; |
| @@ -165,6 +170,14 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { · 88 unmodified lines | ||
| 165 | 170 | filesystem: { |
| 166 | 171 | browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)), |
| 167 | 172 | }, |
| 173 | + sourceControl: { | |
| 174 | + lookupRepository: (input) => | |
| 175 | + transport.request((client) => client[WS_METHODS.sourceControlLookupRepository](input)), | |
| 176 | + cloneRepository: (input) => | |
| 177 | + transport.request((client) => client[WS_METHODS.sourceControlCloneRepository](input)), | |
| 178 | + publishRepository: (input) => | |
| 179 | + transport.request((client) => client[WS_METHODS.sourceControlPublishRepository](input)), | |
| 180 | + }, | |
| 168 | 181 | shell: { |
| 169 | 182 | openInEditor: (input) => |
| 170 | 183 | transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), |
| @@ -55,7 +55,15 @@ import type { · 54 unmodified lines | ||
| 55 | 55 | import type { EnvironmentId } from "./baseSchemas.ts"; |
| 56 | 56 | import { EditorId } from "./editor.ts"; |
| 57 | 57 | import { ServerSettings, type ClientSettings, type ServerSettingsPatch } from "./settings.ts"; |
| 58 | −import type { SourceControlDiscoveryResult } from "./sourceControl.ts"; | |
| 58 | +import type { | |
| 59 | + SourceControlCloneRepositoryInput, | |
| 60 | + SourceControlCloneRepositoryResult, | |
| 61 | + SourceControlDiscoveryResult, | |
| 62 | + SourceControlPublishRepositoryInput, | |
| 63 | + SourceControlPublishRepositoryResult, | |
| 64 | + SourceControlRepositoryInfo, | |
| 65 | + SourceControlRepositoryLookupInput, | |
| 66 | +} from "./sourceControl.ts"; | |
| 59 | 67 | |
| 60 | 68 | export interface ContextMenuItem<T extends string = string> { |
| 61 | 69 | id: T; |
| @@ -258,6 +266,17 @@ export interface EnvironmentApi { · 196 unmodified lines | ||
| 258 | 266 | filesystem: { |
| 259 | 267 | browse: (input: FilesystemBrowseInput) => Promise<FilesystemBrowseResult>; |
| 260 | 268 | }; |
| 269 | + sourceControl: { | |
| 270 | + lookupRepository: ( | |
| 271 | + input: SourceControlRepositoryLookupInput, | |
| 272 | + ) => Promise<SourceControlRepositoryInfo>; | |
| 273 | + cloneRepository: ( | |
| 274 | + input: SourceControlCloneRepositoryInput, | |
| 275 | + ) => Promise<SourceControlCloneRepositoryResult>; | |
| 276 | + publishRepository: ( | |
| 277 | + input: SourceControlPublishRepositoryInput, | |
| 278 | + ) => Promise<SourceControlPublishRepositoryResult>; | |
| 279 | + }; | |
| 261 | 280 | vcs: { |
| 262 | 281 | listRefs: (input: VcsListRefsInput) => Promise<VcsListRefsResult>; |
| 263 | 282 | createWorktree: (input: VcsCreateWorktreeInput) => Promise<VcsCreateWorktreeResult>; |
| @@ -77,7 +77,16 @@ import { · 76 unmodified lines | ||
| 77 | 77 | ServerUpsertKeybindingResult, |
| 78 | 78 | } from "./server.ts"; |
| 79 | 79 | import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; |
| 80 | −import { SourceControlDiscoveryResult } from "./sourceControl.ts"; | |
| 80 | +import { | |
| 81 | + SourceControlCloneRepositoryInput, | |
| 82 | + SourceControlCloneRepositoryResult, | |
| 83 | + SourceControlDiscoveryResult, | |
| 84 | + SourceControlPublishRepositoryInput, | |
| 85 | + SourceControlPublishRepositoryResult, | |
| 86 | + SourceControlRepositoryError, | |
| 87 | + SourceControlRepositoryInfo, | |
| 88 | + SourceControlRepositoryLookupInput, | |
| 89 | +} from "./sourceControl.ts"; | |
| 81 | 90 | import { VcsError } from "./vcs.ts"; |
| 82 | 91 | |
| 83 | 92 | export const WS_METHODS = { |
| @@ -125,6 +134,11 @@ export const WS_METHODS = { · 41 unmodified lines | ||
| 125 | 134 | serverUpdateSettings: "server.updateSettings", |
| 126 | 135 | serverDiscoverSourceControl: "server.discoverSourceControl", |
| 127 | 136 | |
| 137 | + // Source control methods | |
| 138 | + sourceControlLookupRepository: "sourceControl.lookupRepository", | |
| 139 | + sourceControlCloneRepository: "sourceControl.cloneRepository", | |
| 140 | + sourceControlPublishRepository: "sourceControl.publishRepository", | |
| 141 | + | |
| 128 | 142 | // Streaming subscriptions |
| 129 | 143 | subscribeVcsStatus: "subscribeVcsStatus", |
| 130 | 144 | subscribeTerminalEvents: "subscribeTerminalEvents", |
| @@ -175,6 +189,30 @@ export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscov · 44 unmodified lines | ||
| 175 | 189 | success: SourceControlDiscoveryResult, |
| 176 | 190 | }); |
| 177 | 191 | |
| 192 | +export const WsSourceControlLookupRepositoryRpc = Rpc.make( | |
| 193 | + WS_METHODS.sourceControlLookupRepository, | |
| 194 | + { | |
| 195 | + payload: SourceControlRepositoryLookupInput, | |
| 196 | + success: SourceControlRepositoryInfo, | |
| 197 | + error: SourceControlRepositoryError, | |
| 198 | + }, | |
| 199 | +); | |
| 200 | + | |
| 201 | +export const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlCloneRepository, { | |
| 202 | + payload: SourceControlCloneRepositoryInput, | |
| 203 | + success: SourceControlCloneRepositoryResult, | |
| 204 | + error: SourceControlRepositoryError, | |
| 205 | +}); | |
| 206 | + | |
| 207 | +export const WsSourceControlPublishRepositoryRpc = Rpc.make( | |
| 208 | + WS_METHODS.sourceControlPublishRepository, | |
| 209 | + { | |
| 210 | + payload: SourceControlPublishRepositoryInput, | |
| 211 | + success: SourceControlPublishRepositoryResult, | |
| 212 | + error: SourceControlRepositoryError, | |
| 213 | + }, | |
| 214 | +); | |
| 215 | + | |
| 178 | 216 | export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { |
| 179 | 217 | payload: ProjectSearchEntriesInput, |
| 180 | 218 | success: ProjectSearchEntriesResult, |
| @@ -381,6 +419,9 @@ export const WsRpcGroup = RpcGroup.make( · 200 unmodified lines | ||
| 381 | 419 | WsServerGetSettingsRpc, |
| 382 | 420 | WsServerUpdateSettingsRpc, |
| 383 | 421 | WsServerDiscoverSourceControlRpc, |
| 422 | + WsSourceControlLookupRepositoryRpc, | |
| 423 | + WsSourceControlCloneRepositoryRpc, | |
| 424 | + WsSourceControlPublishRepositoryRpc, | |
| 384 | 425 | WsProjectsSearchEntriesRpc, |
| 385 | 426 | WsProjectsWriteFileRpc, |
| 386 | 427 | WsShellOpenInEditorRpc, |
| @@ -43,6 +43,66 @@ export const SourceControlRepositoryCloneUrls = Schema.Struct({ · 42 unmodified lines | ||
| 43 | 43 | }); |
| 44 | 44 | export type SourceControlRepositoryCloneUrls = typeof SourceControlRepositoryCloneUrls.Type; |
| 45 | 45 | |
| 46 | +export const SourceControlRepositoryVisibility = Schema.Literals(["private", "public"]); | |
| 47 | +export type SourceControlRepositoryVisibility = typeof SourceControlRepositoryVisibility.Type; | |
| 48 | + | |
| 49 | +export const SourceControlCloneProtocol = Schema.Literals(["auto", "ssh", "https"]); | |
| 50 | +export type SourceControlCloneProtocol = typeof SourceControlCloneProtocol.Type; | |
| 51 | + | |
| 52 | +export const SourceControlRepositoryInfo = Schema.Struct({ | |
| 53 | + provider: SourceControlProviderKind, | |
| 54 | + nameWithOwner: TrimmedNonEmptyString, | |
| 55 | + url: TrimmedNonEmptyString, | |
| 56 | + sshUrl: TrimmedNonEmptyString, | |
| 57 | +}); | |
| 58 | +export type SourceControlRepositoryInfo = typeof SourceControlRepositoryInfo.Type; | |
| 59 | + | |
| 60 | +export const SourceControlRepositoryLookupInput = Schema.Struct({ | |
| 61 | + provider: SourceControlProviderKind, | |
| 62 | + repository: TrimmedNonEmptyString, | |
| 63 | + cwd: Schema.optional(TrimmedNonEmptyString), | |
| 64 | +}); | |
| 65 | +export type SourceControlRepositoryLookupInput = typeof SourceControlRepositoryLookupInput.Type; | |
| 66 | + | |
| 67 | +export const SourceControlCloneRepositoryInput = Schema.Struct({ | |
| 68 | + provider: Schema.optional(SourceControlProviderKind), | |
| 69 | + repository: Schema.optional(TrimmedNonEmptyString), | |
| 70 | + remoteUrl: Schema.optional(TrimmedNonEmptyString), | |
| 71 | + destinationPath: TrimmedNonEmptyString, | |
| 72 | + protocol: Schema.optional(SourceControlCloneProtocol), | |
| 73 | +}); | |
| 74 | +export type SourceControlCloneRepositoryInput = typeof SourceControlCloneRepositoryInput.Type; | |
| 75 | + | |
| 76 | +export const SourceControlCloneRepositoryResult = Schema.Struct({ | |
| 77 | + cwd: TrimmedNonEmptyString, | |
| 78 | + remoteUrl: TrimmedNonEmptyString, | |
| 79 | + repository: Schema.NullOr(SourceControlRepositoryInfo), | |
| 80 | +}); | |
| 81 | +export type SourceControlCloneRepositoryResult = typeof SourceControlCloneRepositoryResult.Type; | |
| 82 | + | |
| 83 | +export const SourceControlPublishRepositoryInput = Schema.Struct({ | |
| 84 | + cwd: TrimmedNonEmptyString, | |
| 85 | + provider: SourceControlProviderKind, | |
| 86 | + repository: TrimmedNonEmptyString, | |
| 87 | + visibility: SourceControlRepositoryVisibility, | |
| 88 | + remoteName: Schema.optional(TrimmedNonEmptyString), | |
| 89 | + protocol: Schema.optional(SourceControlCloneProtocol), | |
| 90 | +}); | |
| 91 | +export type SourceControlPublishRepositoryInput = typeof SourceControlPublishRepositoryInput.Type; | |
| 92 | + | |
| 93 | +export const SourceControlPublishStatus = Schema.Literals(["pushed", "remote_added"]); | |
| 94 | +export type SourceControlPublishStatus = typeof SourceControlPublishStatus.Type; | |
| 95 | + | |
| 96 | +export const SourceControlPublishRepositoryResult = Schema.Struct({ | |
| 97 | + repository: SourceControlRepositoryInfo, | |
| 98 | + remoteName: TrimmedNonEmptyString, | |
| 99 | + remoteUrl: TrimmedNonEmptyString, | |
| 100 | + branch: TrimmedNonEmptyString, | |
| 101 | + upstreamBranch: Schema.optional(TrimmedNonEmptyString), | |
| 102 | + status: SourceControlPublishStatus, | |
| 103 | +}); | |
| 104 | +export type SourceControlPublishRepositoryResult = typeof SourceControlPublishRepositoryResult.Type; | |
| 105 | + | |
| 46 | 106 | export const SourceControlDiscoveryStatus = Schema.Literals(["available", "missing"]); |
| 47 | 107 | export type SourceControlDiscoveryStatus = typeof SourceControlDiscoveryStatus.Type; |
| 48 | 108 | |
| @@ -63,7 +123,7 @@ export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type; · 14 unmodified lines | ||
| 63 | 123 | |
| 64 | 124 | const SourceControlDiscoveryItemFields = { |
| 65 | 125 | label: TrimmedNonEmptyString, |
| 66 | − executable: TrimmedNonEmptyString, | |
| 126 | + executable: Schema.optional(TrimmedNonEmptyString), | |
| 67 | 127 | implemented: Schema.Boolean, |
| 68 | 128 | status: SourceControlDiscoveryStatus, |
| 69 | 129 | version: Schema.Option(TrimmedNonEmptyString), |
| @@ -109,3 +169,17 @@ export class SourceControlProviderError extends Schema.TaggedErrorClass<SourceCo · 39 unmodified lines | ||
| 109 | 169 | return `Source control provider ${this.provider} failed in ${this.operation}: ${this.detail}`; |
| 110 | 170 | } |
| 111 | 171 | } |
| 172 | + | |
| 173 | +export class SourceControlRepositoryError extends Schema.TaggedErrorClass<SourceControlRepositoryError>()( | |
| 174 | + "SourceControlRepositoryError", | |
| 175 | + { | |
| 176 | + provider: SourceControlProviderKind, | |
| 177 | + operation: Schema.String, | |
| 178 | + detail: Schema.String, | |
| 179 | + cause: Schema.optional(Schema.Defect), | |
| 180 | + }, | |
| 181 | +) { | |
| 182 | + override get message(): string { | |
| 183 | + return `Source control repository operation ${this.operation} failed for ${this.provider}: ${this.detail}`; | |
| 184 | + } | |
| 185 | +} | |