| author | |
| committer | |
| log | d7969264de66dbe0367083af83c19f87162b494c |
| tree | 13ec6282eb3556209d77a4230104331ba6ca5ca8 |
| parent | f7748a0d656f918080828130aceda039457897e0 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>33 files changed, 2502 insertions(+), 157 deletions(-)
apps/server/src/git/GitManager.test.ts+7| ... | ... | @@ -580,6 +580,13 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 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, |
apps/server/src/server.test.ts+10| ... | ... | @@ -117,6 +117,10 @@ import { |
| 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?: { |
| 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?: { |
| 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)({ |
apps/server/src/server.ts+13-6| ... | ... | @@ -54,6 +54,7 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; |
| 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( |
| 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( |
| 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 |
apps/server/src/sourceControl/GitHubCli.test.ts+52| ... | ... | @@ -211,6 +211,58 @@ describe("GitHubCli.layer", () => { |
| 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( |
apps/server/src/sourceControl/GitHubCli.ts+57-1| ... | ... | @@ -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 { |
| 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( |
| 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* () { |
| 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, |
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+4| ... | ... | @@ -130,6 +130,10 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { |
| 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) |
apps/server/src/sourceControl/GitLabCli.test.ts+62-1| ... | ... | @@ -175,7 +175,7 @@ layer("GitLabCli.layer", (it) => { |
| 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) => { |
| 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(""))); |
apps/server/src/sourceControl/GitLabCli.ts+79-3| ... | ... | @@ -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 { |
| 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({ |
| 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( |
| 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( |
| 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* () { |
| 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({ |
apps/server/src/sourceControl/GitLabSourceControlProvider.ts+4| ... | ... | @@ -92,6 +92,10 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { |
| 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) |
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+6-3| ... | ... | @@ -115,6 +115,9 @@ Logged in to github.com account juliusmarminge (keyring) |
| 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 |
| 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 | ); |
apps/server/src/sourceControl/SourceControlDiscovery.ts+44-50| ... | ... | @@ -13,20 +13,22 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 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 { |
| 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> = [ |
| 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 { |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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) => |
apps/server/src/sourceControl/SourceControlProvider.ts+6| ... | ... | @@ -6,6 +6,7 @@ import type { |
| 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 { |
| 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; |
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+1| ... | ... | @@ -58,6 +58,7 @@ function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProv |
| 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 | }); |
apps/server/src/sourceControl/SourceControlRepositoryService.test.ts created+322| ... | ... | @@ -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 | }); |
apps/server/src/sourceControl/SourceControlRepositoryService.ts created+320| ... | ... | @@ -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()); |
apps/server/src/vcs/GitVcsDriver.ts+1| ... | ... | @@ -165,6 +165,7 @@ export interface GitVcsDriverShape { |
| 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, |
apps/server/src/vcs/GitVcsDriverCore.test.ts+36| ... | ... | @@ -326,5 +326,41 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 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 | }); |
apps/server/src/vcs/GitVcsDriverCore.ts+18-1| ... | ... | @@ -1431,7 +1431,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 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* |
| 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) { |
apps/server/src/ws.ts+28| ... | ... | @@ -55,6 +55,7 @@ import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentit |
| 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) => |
| 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) => |
| 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, |
apps/web/src/components/ChatView.browser.tsx+127-6| ... | ... | @@ -206,6 +206,7 @@ function createMockEnvironmentApi(input: { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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)", () => { |
| 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/"); |
apps/web/src/components/CommandPalette.tsx+538-44| ... | ... | @@ -7,6 +7,8 @@ import { |
| 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 { |
| 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 { |
| 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 { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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() { |
| 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> |
apps/web/src/components/GitActionsControl.browser.tsx+2| ... | ... | @@ -100,12 +100,14 @@ vi.mock("~/editorPreferences", () => ({ |
| 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", () => ({ |
apps/web/src/components/GitActionsControl.logic.test.ts+5-22| ... | ... | @@ -730,7 +730,7 @@ describe("when: ref has no upstream configured", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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 |
apps/web/src/components/GitActionsControl.logic.ts+18-13| ... | ... | @@ -26,7 +26,7 @@ export interface GitActionMenuItem { |
| 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( |
| 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( |
| 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) { |
apps/web/src/components/GitActionsControl.tsx+550-2| ... | ... | @@ -3,11 +3,30 @@ import type { |
| 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 { |
| 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 { |
| 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 { |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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) => { |
apps/web/src/components/settings/SourceControlSettings.tsx+9-2| ... | ... | @@ -57,6 +57,8 @@ const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = { |
| 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({ |
| 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({ |
| 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"> |
apps/web/src/environmentApi.ts+5| ... | ... | @@ -23,6 +23,11 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { |
| 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, |
apps/web/src/lib/gitReactQuery.ts+23| ... | ... | @@ -2,6 +2,7 @@ import { |
| 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 = { |
| 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: { |
| 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 | */ |
apps/web/src/localApi.test.ts+5| ... | ... | @@ -56,6 +56,11 @@ const rpcClientMock = { |
| 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 | }, |
apps/web/src/rpc/wsRpcClient.ts+13| ... | ... | @@ -71,6 +71,11 @@ export interface WsRpcClient { |
| 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 { |
| 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)), |
packages/contracts/src/ipc.ts+20-1| ... | ... | @@ -55,7 +55,15 @@ import type { |
| 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 { |
| 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>; |
packages/contracts/src/rpc.ts+42-1| ... | ... | @@ -77,7 +77,16 @@ import { |
| 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 = { |
| 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 |
| 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( |
| 381 | 419 | WsServerGetSettingsRpc, |
| 382 | 420 | WsServerUpdateSettingsRpc, |
| 383 | 421 | WsServerDiscoverSourceControlRpc, |
| 422 | WsSourceControlLookupRepositoryRpc, | |
| 423 | WsSourceControlCloneRepositoryRpc, | |
| 424 | WsSourceControlPublishRepositoryRpc, | |
| 384 | 425 | WsProjectsSearchEntriesRpc, |
| 385 | 426 | WsProjectsWriteFileRpc, |
| 386 | 427 | WsShellOpenInEditorRpc, |
packages/contracts/src/sourceControl.ts+75-1| ... | ... | @@ -43,6 +43,66 @@ export const SourceControlRepositoryCloneUrls = Schema.Struct({ |
| 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; |
| 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 |
| 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 | } |