| 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,6 +580,13 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 580 | cwd: input.cwd, | 580 | cwd: input.cwd, |
| 581 | args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], | 581 | args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], |
| 582 | }).pipe(Effect.map((result) => JSON.parse(result.stdout))), | 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 | checkoutPullRequest: (input) => | 590 | checkoutPullRequest: (input) => |
| 584 | execute({ | 591 | execute({ |
| 585 | cwd: input.cwd, | 592 | cwd: input.cwd, |
apps/server/src/server.test.ts+10| ... | @@ -117,6 +117,10 @@ import { | ... | @@ -117,6 +117,10 @@ import { |
| 117 | } from "./vcs/VcsDriverRegistry.ts"; | 117 | } from "./vcs/VcsDriverRegistry.ts"; |
| 118 | import { layer as VcsProvisioningServiceLayer } from "./vcs/VcsProvisioningService.ts"; | 118 | import { layer as VcsProvisioningServiceLayer } from "./vcs/VcsProvisioningService.ts"; |
| 119 | import { layer as GitWorkflowServiceLayer } from "./git/GitWorkflowService.ts"; | 119 | import { layer as GitWorkflowServiceLayer } from "./git/GitWorkflowService.ts"; |
| 120 | import { | ||
| 121 | SourceControlRepositoryService, | ||
| 122 | type SourceControlRepositoryServiceShape, | ||
| 123 | } from "./sourceControl/SourceControlRepositoryService.ts"; | ||
| 120 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; | 124 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; |
| 121 | import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; | 125 | import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; |
| 122 | 126 | ||
| ... | @@ -330,6 +334,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -330,6 +334,7 @@ const buildAppUnderTest = (options?: { |
| 330 | vcsDriverRegistry?: Partial<VcsDriverRegistryShape>; | 334 | vcsDriverRegistry?: Partial<VcsDriverRegistryShape>; |
| 331 | gitVcsDriver?: Partial<GitVcsDriver.GitVcsDriverShape>; | 335 | gitVcsDriver?: Partial<GitVcsDriver.GitVcsDriverShape>; |
| 332 | gitManager?: Partial<GitManagerShape>; | 336 | gitManager?: Partial<GitManagerShape>; |
| 337 | sourceControlRepositoryService?: Partial<SourceControlRepositoryServiceShape>; | ||
| 333 | vcsStatusBroadcaster?: Partial<VcsStatusBroadcasterShape>; | 338 | vcsStatusBroadcaster?: Partial<VcsStatusBroadcasterShape>; |
| 334 | projectSetupScriptRunner?: Partial<ProjectSetupScriptRunnerShape>; | 339 | projectSetupScriptRunner?: Partial<ProjectSetupScriptRunnerShape>; |
| 335 | terminalManager?: Partial<TerminalManagerShape>; | 340 | terminalManager?: Partial<TerminalManagerShape>; |
| ... | @@ -545,6 +550,11 @@ const buildAppUnderTest = (options?: { | ... | @@ -545,6 +550,11 @@ const buildAppUnderTest = (options?: { |
| 545 | Layer.provide(gitVcsDriverLayer), | 550 | Layer.provide(gitVcsDriverLayer), |
| 546 | Layer.provide(gitWorkflowLayer), | 551 | Layer.provide(gitWorkflowLayer), |
| 547 | Layer.provide(vcsProvisioningLayer), | 552 | Layer.provide(vcsProvisioningLayer), |
| 553 | Layer.provide( | ||
| 554 | Layer.mock(SourceControlRepositoryService)({ | ||
| 555 | ...options?.layers?.sourceControlRepositoryService, | ||
| 556 | }), | ||
| 557 | ), | ||
| 548 | Layer.provideMerge(vcsStatusBroadcasterLayer), | 558 | Layer.provideMerge(vcsStatusBroadcasterLayer), |
| 549 | Layer.provide( | 559 | Layer.provide( |
| 550 | Layer.mock(ProjectSetupScriptRunner)({ | 560 | Layer.mock(ProjectSetupScriptRunner)({ |
apps/server/src/server.ts+13-6| ... | @@ -54,6 +54,7 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; | ... | @@ -54,6 +54,7 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; |
| 54 | import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; | 54 | import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; |
| 55 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; | 55 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 56 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; | 56 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; |
| 57 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; | ||
| 57 | import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; | 58 | import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; |
| 58 | import { ObservabilityLive } from "./observability/Layers/Observability.ts"; | 59 | import { ObservabilityLive } from "./observability/Layers/Observability.ts"; |
| 59 | import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; | 60 | import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; |
| ... | @@ -161,15 +162,15 @@ const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( | ... | @@ -161,15 +162,15 @@ const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( |
| 161 | Layer.provide(VcsProjectConfig.layer), | 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 | const GitManagerLayerLive = GitManager.layer.pipe( | 170 | const GitManagerLayerLive = GitManager.layer.pipe( |
| 165 | Layer.provideMerge(ProjectSetupScriptRunnerLive), | 171 | Layer.provideMerge(ProjectSetupScriptRunnerLive), |
| 166 | Layer.provideMerge(GitVcsDriver.layer), | 172 | Layer.provideMerge(GitVcsDriver.layer), |
| 167 | Layer.provideMerge( | 173 | Layer.provideMerge(SourceControlProviderRegistryLayerLive), |
| 168 | SourceControlProviderRegistry.layer.pipe( | ||
| 169 | Layer.provide(Layer.mergeAll(GitHubCli.layer, GitLabCli.layer)), | ||
| 170 | Layer.provideMerge(VcsDriverRegistryLayerLive), | ||
| 171 | ), | ||
| 172 | ), | ||
| 173 | Layer.provideMerge(TextGeneration.layer), | 174 | Layer.provideMerge(TextGeneration.layer), |
| 174 | ); | 175 | ); |
| 175 | 176 | ||
| ... | @@ -183,11 +184,17 @@ const GitWorkflowLayerLive = GitWorkflowService.layer.pipe( | ... | @@ -183,11 +184,17 @@ const GitWorkflowLayerLive = GitWorkflowService.layer.pipe( |
| 183 | Layer.provideMerge(GitLayerLive), | 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 | const VcsLayerLive = Layer.empty.pipe( | 192 | const VcsLayerLive = Layer.empty.pipe( |
| 187 | Layer.provideMerge(VcsProjectConfig.layer), | 193 | Layer.provideMerge(VcsProjectConfig.layer), |
| 188 | Layer.provideMerge(VcsDriverRegistryLayerLive), | 194 | Layer.provideMerge(VcsDriverRegistryLayerLive), |
| 189 | Layer.provideMerge(VcsProvisioningService.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))), | 195 | Layer.provideMerge(VcsProvisioningService.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))), |
| 190 | Layer.provideMerge(GitWorkflowLayerLive), | 196 | Layer.provideMerge(GitWorkflowLayerLive), |
| 197 | Layer.provideMerge(SourceControlRepositoryServiceLayerLive), | ||
| 191 | Layer.provideMerge(VcsStatusBroadcaster.layer.pipe(Layer.provide(GitWorkflowLayerLive))), | 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,6 +211,58 @@ describe("GitHubCli.layer", () => { |
| 211 | }).pipe(Effect.provide(layer)), | 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 | it.effect("surfaces a friendly error when the pull request is not found", () => | 266 | it.effect("surfaces a friendly error when the pull request is not found", () => |
| 215 | Effect.gen(function* () { | 267 | Effect.gen(function* () { |
| 216 | mockRun.mockReturnValueOnce( | 268 | mockRun.mockReturnValueOnce( |
apps/server/src/sourceControl/GitHubCli.ts+57-1| ... | @@ -1,6 +1,10 @@ | ... | @@ -1,6 +1,10 @@ |
| 1 | import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"; | 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 | import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; | 9 | import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; |
| 6 | import { | 10 | import { |
| ... | @@ -62,6 +66,12 @@ export interface GitHubCliShape { | ... | @@ -62,6 +66,12 @@ export interface GitHubCliShape { |
| 62 | readonly repository: string; | 66 | readonly repository: string; |
| 63 | }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>; | 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 | readonly createPullRequest: (input: { | 75 | readonly createPullRequest: (input: { |
| 66 | readonly cwd: string; | 76 | readonly cwd: string; |
| 67 | readonly baseBranch: string; | 77 | readonly baseBranch: string; |
| ... | @@ -160,6 +170,43 @@ function normalizeRepositoryCloneUrls( | ... | @@ -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 | function decodeGitHubJson<S extends Schema.Top>( | 210 | function decodeGitHubJson<S extends Schema.Top>( |
| 164 | raw: string, | 211 | raw: string, |
| 165 | schema: S, | 212 | schema: S, |
| ... | @@ -281,6 +328,15 @@ export const make = Effect.fn("makeGitHubCli")(function* () { | ... | @@ -281,6 +328,15 @@ export const make = Effect.fn("makeGitHubCli")(function* () { |
| 281 | ), | 328 | ), |
| 282 | Effect.map(normalizeRepositoryCloneUrls), | 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 | createPullRequest: (input) => | 340 | createPullRequest: (input) => |
| 285 | execute({ | 341 | execute({ |
| 286 | cwd: input.cwd, | 342 | cwd: input.cwd, |
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+4| ... | @@ -130,6 +130,10 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { | ... | @@ -130,6 +130,10 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { |
| 130 | github | 130 | github |
| 131 | .getRepositoryCloneUrls(input) | 131 | .getRepositoryCloneUrls(input) |
| 132 | .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), | 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 | getDefaultBranch: (input) => | 137 | getDefaultBranch: (input) => |
| 134 | github | 138 | github |
| 135 | .getDefaultBranch(input) | 139 | .getDefaultBranch(input) |
apps/server/src/sourceControl/GitLabCli.test.ts+62-1| ... | @@ -175,7 +175,7 @@ layer("GitLabCli.layer", (it) => { | ... | @@ -175,7 +175,7 @@ layer("GitLabCli.layer", (it) => { |
| 175 | 175 | ||
| 176 | assert.deepStrictEqual(result, { | 176 | assert.deepStrictEqual(result, { |
| 177 | nameWithOwner: "octocat/t3code", | 177 | nameWithOwner: "octocat/t3code", |
| 178 | url: "https://gitlab.com/octocat/t3code.git", | 178 | url: "https://gitlab.com/octocat/t3code", |
| 179 | sshUrl: "git@gitlab.com:octocat/t3code.git", | 179 | sshUrl: "git@gitlab.com:octocat/t3code.git", |
| 180 | }); | 180 | }); |
| 181 | }), | 181 | }), |
| ... | @@ -217,6 +217,67 @@ layer("GitLabCli.layer", (it) => { | ... | @@ -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 | it.effect("does not pass unsupported force flags when checking out merge requests", () => | 281 | it.effect("does not pass unsupported force flags when checking out merge requests", () => |
| 221 | Effect.gen(function* () { | 282 | Effect.gen(function* () { |
| 222 | mockedRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); | 283 | mockedRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); |
apps/server/src/sourceControl/GitLabCli.ts+79-3| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, type DateTime } from "effect"; | 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 | import { | 5 | import { |
| 6 | decodeGitLabMergeRequestJson, | 6 | decodeGitLabMergeRequestJson, |
| ... | @@ -66,6 +66,12 @@ export interface GitLabCliShape { | ... | @@ -66,6 +66,12 @@ export interface GitLabCliShape { |
| 66 | readonly repository: string; | 66 | readonly repository: string; |
| 67 | }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>; | 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 | readonly createMergeRequest: (input: { | 75 | readonly createMergeRequest: (input: { |
| 70 | readonly cwd: string; | 76 | readonly cwd: string; |
| 71 | readonly baseBranch: string; | 77 | readonly baseBranch: string; |
| ... | @@ -161,12 +167,16 @@ const RawGitLabDefaultBranchSchema = Schema.Struct({ | ... | @@ -161,12 +167,16 @@ const RawGitLabDefaultBranchSchema = Schema.Struct({ |
| 161 | default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | 167 | default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 162 | }); | 168 | }); |
| 163 | 169 | ||
| 170 | const RawGitLabNamespaceSchema = Schema.Struct({ | ||
| 171 | id: Schema.Number, | ||
| 172 | }); | ||
| 173 | |||
| 164 | function normalizeRepositoryCloneUrls( | 174 | function normalizeRepositoryCloneUrls( |
| 165 | raw: Schema.Schema.Type<typeof RawGitLabRepositoryCloneUrlsSchema>, | 175 | raw: Schema.Schema.Type<typeof RawGitLabRepositoryCloneUrlsSchema>, |
| 166 | ): GitLabRepositoryCloneUrls { | 176 | ): GitLabRepositoryCloneUrls { |
| 167 | return { | 177 | return { |
| 168 | nameWithOwner: raw.path_with_namespace, | 178 | nameWithOwner: raw.path_with_namespace, |
| 169 | url: raw.http_url_to_repo || raw.web_url, | 179 | url: raw.web_url, |
| 170 | sshUrl: raw.ssh_url_to_repo, | 180 | sshUrl: raw.ssh_url_to_repo, |
| 171 | }; | 181 | }; |
| 172 | } | 182 | } |
| ... | @@ -174,7 +184,7 @@ function normalizeRepositoryCloneUrls( | ... | @@ -174,7 +184,7 @@ function normalizeRepositoryCloneUrls( |
| 174 | function decodeGitLabJson<S extends Schema.Top>( | 184 | function decodeGitLabJson<S extends Schema.Top>( |
| 175 | raw: string, | 185 | raw: string, |
| 176 | schema: S, | 186 | schema: S, |
| 177 | operation: "getRepositoryCloneUrls" | "getDefaultBranch", | 187 | operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", |
| 178 | invalidDetail: string, | 188 | invalidDetail: string, |
| 179 | ): Effect.Effect<S["Type"], GitLabCliError, S["DecodingServices"]> { | 189 | ): Effect.Effect<S["Type"], GitLabCliError, S["DecodingServices"]> { |
| 180 | return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( | 190 | return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( |
| ... | @@ -228,6 +238,19 @@ function toSummaryWithOptionalUpdatedAt( | ... | @@ -228,6 +238,19 @@ function toSummaryWithOptionalUpdatedAt( |
| 228 | return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary; | 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 | export const make = Effect.fn("makeGitLabCli")(function* () { | 254 | export const make = Effect.fn("makeGitLabCli")(function* () { |
| 232 | const process = yield* VcsProcess; | 255 | const process = yield* VcsProcess; |
| 233 | 256 | ||
| ... | @@ -320,6 +343,59 @@ export const make = Effect.fn("makeGitLabCli")(function* () { | ... | @@ -320,6 +343,59 @@ export const make = Effect.fn("makeGitLabCli")(function* () { |
| 320 | ), | 343 | ), |
| 321 | Effect.map(normalizeRepositoryCloneUrls), | 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 | createMergeRequest: (input) => { | 399 | createMergeRequest: (input) => { |
| 324 | const sourceProject = sourceProjectIdentifier(input.source); | 400 | const sourceProject = sourceProjectIdentifier(input.source); |
| 325 | return execute({ | 401 | return execute({ |
apps/server/src/sourceControl/GitLabSourceControlProvider.ts+4| ... | @@ -92,6 +92,10 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { | ... | @@ -92,6 +92,10 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { |
| 92 | gitlab | 92 | gitlab |
| 93 | .getRepositoryCloneUrls(input) | 93 | .getRepositoryCloneUrls(input) |
| 94 | .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), | 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 | getDefaultBranch: (input) => | 99 | getDefaultBranch: (input) => |
| 96 | gitlab | 100 | gitlab |
| 97 | .getDefaultBranch(input) | 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,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 | }).pipe(Effect.provide(testLayer)); | 121 | }).pipe(Effect.provide(testLayer)); |
| 119 | }); | 122 | }); |
| 120 | 123 | ||
| ... | @@ -204,9 +207,9 @@ Logged in as bitbucket-user | ... | @@ -204,9 +207,9 @@ Logged in as bitbucket-user |
| 204 | }, | 207 | }, |
| 205 | { | 208 | { |
| 206 | kind: "bitbucket", | 209 | kind: "bitbucket", |
| 207 | auth: "authenticated", | 210 | auth: "unknown", |
| 208 | account: Option.some("bitbucket-user"), | 211 | account: Option.none(), |
| 209 | detail: 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,20 +13,22 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 13 | 13 | ||
| 14 | interface DiscoveryProbe { | 14 | interface DiscoveryProbe { |
| 15 | readonly label: string; | 15 | readonly label: string; |
| 16 | readonly executable: string; | 16 | readonly executable?: string; |
| 17 | readonly versionArgs: ReadonlyArray<string>; | 17 | readonly versionArgs?: ReadonlyArray<string>; |
| 18 | readonly implemented: boolean; | 18 | readonly implemented: boolean; |
| 19 | readonly installHint: string; | 19 | readonly installHint: string; |
| 20 | } | 20 | } |
| 21 | 21 | ||
| 22 | type VcsProbe = DiscoveryProbe & { | 22 | type VcsProbe = DiscoveryProbe & { |
| 23 | readonly kind: VcsDriverKind; | 23 | readonly kind: VcsDriverKind; |
| 24 | readonly executable: string; | ||
| 25 | readonly versionArgs: ReadonlyArray<string>; | ||
| 24 | }; | 26 | }; |
| 25 | 27 | ||
| 26 | type ProviderProbe = DiscoveryProbe & { | 28 | type ProviderProbe = DiscoveryProbe & { |
| 27 | readonly kind: SourceControlProviderKind; | 29 | readonly kind: SourceControlProviderKind; |
| 28 | readonly authArgs: ReadonlyArray<string>; | 30 | readonly authArgs?: ReadonlyArray<string>; |
| 29 | readonly parseAuth: (input: AuthProbeInput) => SourceControlProviderAuth; | 31 | readonly parseAuth?: (input: AuthProbeInput) => SourceControlProviderAuth; |
| 30 | }; | 32 | }; |
| 31 | 33 | ||
| 32 | interface AuthProbeInput { | 34 | interface AuthProbeInput { |
| ... | @@ -38,7 +40,7 @@ interface AuthProbeInput { | ... | @@ -38,7 +40,7 @@ interface AuthProbeInput { |
| 38 | interface DiscoveryProbeResult<Kind extends string> { | 40 | interface DiscoveryProbeResult<Kind extends string> { |
| 39 | readonly kind: Kind; | 41 | readonly kind: Kind; |
| 40 | readonly label: string; | 42 | readonly label: string; |
| 41 | readonly executable: string; | 43 | readonly executable?: string; |
| 42 | readonly implemented: boolean; | 44 | readonly implemented: boolean; |
| 43 | readonly status: "available" | "missing"; | 45 | readonly status: "available" | "missing"; |
| 44 | readonly version: Option.Option<string>; | 46 | readonly version: Option.Option<string>; |
| ... | @@ -101,12 +103,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ | ... | @@ -101,12 +103,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ |
| 101 | { | 103 | { |
| 102 | kind: "bitbucket", | 104 | kind: "bitbucket", |
| 103 | label: "Bitbucket", | 105 | label: "Bitbucket", |
| 104 | executable: "bb", | ||
| 105 | versionArgs: ["--version"], | ||
| 106 | authArgs: ["auth", "status"], | ||
| 107 | parseAuth: parseBitbucketAuth, | ||
| 108 | implemented: false, | 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,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 | export interface SourceControlDiscoveryShape { | 267 | export interface SourceControlDiscoveryShape { |
| 301 | readonly discover: Effect.Effect<SourceControlDiscoveryResult>; | 268 | readonly discover: Effect.Effect<SourceControlDiscoveryResult>; |
| 302 | } | 269 | } |
| ... | @@ -314,12 +281,27 @@ export const layer = Layer.effect( | ... | @@ -314,12 +281,27 @@ export const layer = Layer.effect( |
| 314 | 281 | ||
| 315 | const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>( | 282 | const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>( |
| 316 | input: DiscoveryProbe & { readonly kind: Kind }, | 283 | input: DiscoveryProbe & { readonly kind: Kind }, |
| 317 | ): Effect.Effect<DiscoveryProbeResult<Kind>> => | 284 | ): Effect.Effect<DiscoveryProbeResult<Kind>> => { |
| 318 | process | 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 | .run({ | 301 | .run({ |
| 320 | operation: "source-control.discovery.probe", | 302 | operation: "source-control.discovery.probe", |
| 321 | command: input.executable, | 303 | command: executable, |
| 322 | args: input.versionArgs, | 304 | args: versionArgs, |
| 323 | cwd: config.cwd, | 305 | cwd: config.cwd, |
| 324 | timeoutMs: 5_000, | 306 | timeoutMs: 5_000, |
| 325 | maxOutputBytes: 8_000, | 307 | maxOutputBytes: 8_000, |
| ... | @@ -331,7 +313,7 @@ export const layer = Layer.effect( | ... | @@ -331,7 +313,7 @@ export const layer = Layer.effect( |
| 331 | ({ | 313 | ({ |
| 332 | kind: input.kind, | 314 | kind: input.kind, |
| 333 | label: input.label, | 315 | label: input.label, |
| 334 | executable: input.executable, | 316 | executable, |
| 335 | implemented: input.implemented, | 317 | implemented: input.implemented, |
| 336 | status: "available" as const, | 318 | status: "available" as const, |
| 337 | version: Option.orElse(firstNonEmptyLine(result.stdout), () => | 319 | version: Option.orElse(firstNonEmptyLine(result.stdout), () => |
| ... | @@ -345,7 +327,7 @@ export const layer = Layer.effect( | ... | @@ -345,7 +327,7 @@ export const layer = Layer.effect( |
| 345 | Effect.succeed({ | 327 | Effect.succeed({ |
| 346 | kind: input.kind, | 328 | kind: input.kind, |
| 347 | label: input.label, | 329 | label: input.label, |
| 348 | executable: input.executable, | 330 | executable, |
| 349 | implemented: input.implemented, | 331 | implemented: input.implemented, |
| 350 | status: "missing" as const, | 332 | status: "missing" as const, |
| 351 | version: Option.none<string>(), | 333 | version: Option.none<string>(), |
| ... | @@ -354,10 +336,22 @@ export const layer = Layer.effect( | ... | @@ -354,10 +336,22 @@ export const layer = Layer.effect( |
| 354 | } satisfies DiscoveryProbeResult<Kind>), | 336 | } satisfies DiscoveryProbeResult<Kind>), |
| 355 | ), | 337 | ), |
| 356 | ); | 338 | ); |
| 339 | }; | ||
| 357 | 340 | ||
| 358 | const probeProvider = (input: ProviderProbe) => | 341 | const probeProvider = (input: ProviderProbe) => |
| 359 | probe(input).pipe( | 342 | probe(input).pipe( |
| 360 | Effect.flatMap((item) => { | 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 | if (item.status !== "available") { | 355 | if (item.status !== "available") { |
| 362 | return Effect.succeed({ | 356 | return Effect.succeed({ |
| 363 | ...item, | 357 | ...item, |
| ... | @@ -368,8 +362,8 @@ export const layer = Layer.effect( | ... | @@ -368,8 +362,8 @@ export const layer = Layer.effect( |
| 368 | return process | 362 | return process |
| 369 | .run({ | 363 | .run({ |
| 370 | operation: "source-control.discovery.auth", | 364 | operation: "source-control.discovery.auth", |
| 371 | command: input.executable, | 365 | command: executable, |
| 372 | args: input.authArgs, | 366 | args: authArgs, |
| 373 | cwd: config.cwd, | 367 | cwd: config.cwd, |
| 374 | allowNonZeroExit: true, | 368 | allowNonZeroExit: true, |
| 375 | timeoutMs: 5_000, | 369 | timeoutMs: 5_000, |
| ... | @@ -381,7 +375,7 @@ export const layer = Layer.effect( | ... | @@ -381,7 +375,7 @@ export const layer = Layer.effect( |
| 381 | (result) => | 375 | (result) => |
| 382 | ({ | 376 | ({ |
| 383 | ...item, | 377 | ...item, |
| 384 | auth: input.parseAuth(result), | 378 | auth: parseAuth(result), |
| 385 | }) satisfies SourceControlProviderDiscoveryItem, | 379 | }) satisfies SourceControlProviderDiscoveryItem, |
| 386 | ), | 380 | ), |
| 387 | Effect.catch((cause) => | 381 | Effect.catch((cause) => |
apps/server/src/sourceControl/SourceControlProvider.ts+6| ... | @@ -6,6 +6,7 @@ import type { | ... | @@ -6,6 +6,7 @@ import type { |
| 6 | SourceControlProviderInfo, | 6 | SourceControlProviderInfo, |
| 7 | SourceControlProviderKind, | 7 | SourceControlProviderKind, |
| 8 | SourceControlRepositoryCloneUrls, | 8 | SourceControlRepositoryCloneUrls, |
| 9 | SourceControlRepositoryVisibility, | ||
| 9 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 10 | 11 | ||
| 11 | export interface SourceControlProviderContext { | 12 | export interface SourceControlProviderContext { |
| ... | @@ -50,6 +51,11 @@ export interface SourceControlProviderShape { | ... | @@ -50,6 +51,11 @@ export interface SourceControlProviderShape { |
| 50 | readonly context?: SourceControlProviderContext; | 51 | readonly context?: SourceControlProviderContext; |
| 51 | readonly repository: string; | 52 | readonly repository: string; |
| 52 | }) => Effect.Effect<SourceControlRepositoryCloneUrls, SourceControlProviderError>; | 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 | readonly getDefaultBranch: (input: { | 59 | readonly getDefaultBranch: (input: { |
| 54 | readonly cwd: string; | 60 | readonly cwd: string; |
| 55 | readonly context?: SourceControlProviderContext; | 61 | readonly context?: SourceControlProviderContext; |
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+1| ... | @@ -58,6 +58,7 @@ function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProv | ... | @@ -58,6 +58,7 @@ function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProv |
| 58 | getChangeRequest: () => unsupported("getChangeRequest"), | 58 | getChangeRequest: () => unsupported("getChangeRequest"), |
| 59 | createChangeRequest: () => unsupported("createChangeRequest"), | 59 | createChangeRequest: () => unsupported("createChangeRequest"), |
| 60 | getRepositoryCloneUrls: () => unsupported("getRepositoryCloneUrls"), | 60 | getRepositoryCloneUrls: () => unsupported("getRepositoryCloneUrls"), |
| 61 | createRepository: () => unsupported("createRepository"), | ||
| 61 | getDefaultBranch: () => unsupported("getDefaultBranch"), | 62 | getDefaultBranch: () => unsupported("getDefaultBranch"), |
| 62 | checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), | 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,6 +165,7 @@ export interface GitVcsDriverShape { |
| 165 | readonly pushCurrentBranch: ( | 165 | readonly pushCurrentBranch: ( |
| 166 | cwd: string, | 166 | cwd: string, |
| 167 | fallbackBranch: string | null, | 167 | fallbackBranch: string | null, |
| 168 | options?: { readonly remoteName?: string | null }, | ||
| 168 | ) => Effect.Effect<GitPushResult, GitCommandError>; | 169 | ) => Effect.Effect<GitPushResult, GitCommandError>; |
| 169 | readonly readRangeContext: ( | 170 | readonly readRangeContext: ( |
| 170 | cwd: string, | 171 | cwd: string, |
apps/server/src/vcs/GitVcsDriverCore.test.ts+36| ... | @@ -326,5 +326,41 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { | ... | @@ -326,5 +326,41 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 326 | assert.notEqual(badBranch.exitCode, 0); | 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,7 +1431,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1431 | }); | 1431 | }); |
| 1432 | 1432 | ||
| 1433 | const pushCurrentBranch: GitVcsDriverShape["pushCurrentBranch"] = Effect.fn("pushCurrentBranch")( | 1433 | const pushCurrentBranch: GitVcsDriverShape["pushCurrentBranch"] = Effect.fn("pushCurrentBranch")( |
| 1434 | function* (cwd, fallbackBranch) { | 1434 | function* (cwd, fallbackBranch, options) { |
| 1435 | const details = yield* statusDetails(cwd); | 1435 | const details = yield* statusDetails(cwd); |
| 1436 | const branch = details.branch ?? fallbackBranch; | 1436 | const branch = details.branch ?? fallbackBranch; |
| 1437 | if (!branch) { | 1437 | if (!branch) { |
| ... | @@ -1443,6 +1443,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -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 | const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; | 1463 | const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; |
| 1447 | if (hasNoLocalDelta) { | 1464 | if (hasNoLocalDelta) { |
| 1448 | if (details.hasUpstream) { | 1465 | if (details.hasUpstream) { |
apps/server/src/ws.ts+28| ... | @@ -55,6 +55,7 @@ import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentit | ... | @@ -55,6 +55,7 @@ import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentit |
| 55 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; | 55 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; |
| 56 | import { ServerAuth } from "./auth/Services/ServerAuth.ts"; | 56 | import { ServerAuth } from "./auth/Services/ServerAuth.ts"; |
| 57 | import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; | 57 | import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; |
| 58 | import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts"; | ||
| 58 | import * as VcsProcess from "./vcs/VcsProcess.ts"; | 59 | import * as VcsProcess from "./vcs/VcsProcess.ts"; |
| 59 | import { | 60 | import { |
| 60 | BootstrapCredentialService, | 61 | BootstrapCredentialService, |
| ... | @@ -154,6 +155,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -154,6 +155,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 154 | const serverEnvironment = yield* ServerEnvironment; | 155 | const serverEnvironment = yield* ServerEnvironment; |
| 155 | const serverAuth = yield* ServerAuth; | 156 | const serverAuth = yield* ServerAuth; |
| 156 | const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; | 157 | const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; |
| 158 | const sourceControlRepositories = yield* SourceControlRepositoryService; | ||
| 157 | const bootstrapCredentials = yield* BootstrapCredentialService; | 159 | const bootstrapCredentials = yield* BootstrapCredentialService; |
| 158 | const sessions = yield* SessionCredentialService; | 160 | const sessions = yield* SessionCredentialService; |
| 159 | const serverCommandId = (tag: string) => | 161 | const serverCommandId = (tag: string) => |
| ... | @@ -794,6 +796,32 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -794,6 +796,32 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 794 | "rpc.aggregate": "server", | 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 | [WS_METHODS.projectsSearchEntries]: (input) => | 825 | [WS_METHODS.projectsSearchEntries]: (input) => |
| 798 | observeRpcEffect( | 826 | observeRpcEffect( |
| 799 | WS_METHODS.projectsSearchEntries, | 827 | WS_METHODS.projectsSearchEntries, |
apps/web/src/components/ChatView.browser.tsx+127-6| ... | @@ -206,6 +206,7 @@ function createMockEnvironmentApi(input: { | ... | @@ -206,6 +206,7 @@ function createMockEnvironmentApi(input: { |
| 206 | filesystem: { | 206 | filesystem: { |
| 207 | browse: input.browse, | 207 | browse: input.browse, |
| 208 | }, | 208 | }, |
| 209 | sourceControl: {} as EnvironmentApi["sourceControl"], | ||
| 209 | vcs: {} as EnvironmentApi["vcs"], | 210 | vcs: {} as EnvironmentApi["vcs"], |
| 210 | git: {} as EnvironmentApi["git"], | 211 | git: {} as EnvironmentApi["git"], |
| 211 | orchestration: { | 212 | orchestration: { |
| ... | @@ -4257,8 +4258,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -4257,8 +4258,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 4257 | }); | 4258 | }); |
| 4258 | 4259 | ||
| 4259 | try { | 4260 | try { |
| 4260 | await waitForServerConfigToApply(); | 4261 | await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); |
| 4261 | await waitForCommandPaletteShortcutLabel(); | ||
| 4262 | const palette = page.getByTestId("command-palette"); | 4262 | const palette = page.getByTestId("command-palette"); |
| 4263 | await openCommandPaletteFromTrigger(); | 4263 | await openCommandPaletteFromTrigger(); |
| 4264 | 4264 | ||
| ... | @@ -4310,8 +4310,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -4310,8 +4310,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 4310 | }); | 4310 | }); |
| 4311 | 4311 | ||
| 4312 | try { | 4312 | try { |
| 4313 | await waitForServerConfigToApply(); | 4313 | await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); |
| 4314 | await waitForCommandPaletteShortcutLabel(); | ||
| 4315 | const palette = page.getByTestId("command-palette"); | 4314 | const palette = page.getByTestId("command-palette"); |
| 4316 | await openCommandPaletteFromTrigger(); | 4315 | await openCommandPaletteFromTrigger(); |
| 4317 | 4316 | ||
| ... | @@ -4384,13 +4383,13 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -4384,13 +4383,13 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 4384 | }); | 4383 | }); |
| 4385 | 4384 | ||
| 4386 | try { | 4385 | try { |
| 4387 | await waitForServerConfigToApply(); | 4386 | await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); |
| 4388 | await waitForCommandPaletteShortcutLabel(); | ||
| 4389 | const palette = page.getByTestId("command-palette"); | 4387 | const palette = page.getByTestId("command-palette"); |
| 4390 | await openCommandPaletteFromTrigger(); | 4388 | await openCommandPaletteFromTrigger(); |
| 4391 | 4389 | ||
| 4392 | await expect.element(palette).toBeInTheDocument(); | 4390 | await expect.element(palette).toBeInTheDocument(); |
| 4393 | await palette.getByText("Add project", { exact: true }).click(); | 4391 | await palette.getByText("Add project", { exact: true }).click(); |
| 4392 | await palette.getByText("Local folder", { exact: true }).click(); | ||
| 4394 | 4393 | ||
| 4395 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); | 4394 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); |
| 4396 | await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/"); | 4395 | await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/"); |
| ... | @@ -4437,6 +4436,126 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -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 | it("opens add project browse mode from the sidebar add button", async () => { | 4559 | it("opens add project browse mode from the sidebar add button", async () => { |
| 4441 | const mounted = await mountChatView({ | 4560 | const mounted = await mountChatView({ |
| 4442 | viewport: DEFAULT_VIEWPORT, | 4561 | viewport: DEFAULT_VIEWPORT, |
| ... | @@ -4789,6 +4908,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -4789,6 +4908,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 4789 | 4908 | ||
| 4790 | await expect.element(palette).toBeInTheDocument(); | 4909 | await expect.element(palette).toBeInTheDocument(); |
| 4791 | await palette.getByText("Add project", { exact: true }).click(); | 4910 | await palette.getByText("Add project", { exact: true }).click(); |
| 4911 | await palette.getByText("Local folder", { exact: true }).click(); | ||
| 4792 | await expect.element(palette.getByText("Environments", { exact: true })).toBeInTheDocument(); | 4912 | await expect.element(palette.getByText("Environments", { exact: true })).toBeInTheDocument(); |
| 4793 | await expect | 4913 | await expect |
| 4794 | .element(palette.getByText("This device", { exact: true }).first()) | 4914 | .element(palette.getByText("This device", { exact: true }).first()) |
| ... | @@ -5004,6 +5124,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -5004,6 +5124,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 5004 | 5124 | ||
| 5005 | await expect.element(palette).toBeInTheDocument(); | 5125 | await expect.element(palette).toBeInTheDocument(); |
| 5006 | await palette.getByText("Add project", { exact: true }).click(); | 5126 | await palette.getByText("Add project", { exact: true }).click(); |
| 5127 | await palette.getByText("Local folder", { exact: true }).click(); | ||
| 5007 | 5128 | ||
| 5008 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); | 5129 | const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); |
| 5009 | await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/"); | 5130 | await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/"); |
apps/web/src/components/CommandPalette.tsx+538-44| ... | @@ -7,6 +7,8 @@ import { | ... | @@ -7,6 +7,8 @@ import { |
| 7 | type FilesystemBrowseResult, | 7 | type FilesystemBrowseResult, |
| 8 | type ProjectId, | 8 | type ProjectId, |
| 9 | ProviderInstanceId, | 9 | ProviderInstanceId, |
| 10 | type SourceControlProviderKind, | ||
| 11 | type SourceControlRepositoryInfo, | ||
| 10 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 11 | import { useQuery, useQueryClient } from "@tanstack/react-query"; | 13 | import { useQuery, useQueryClient } from "@tanstack/react-query"; |
| 12 | import { useNavigate, useParams } from "@tanstack/react-router"; | 14 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| ... | @@ -17,6 +19,7 @@ import { | ... | @@ -17,6 +19,7 @@ import { |
| 17 | CornerLeftUpIcon, | 19 | CornerLeftUpIcon, |
| 18 | FolderIcon, | 20 | FolderIcon, |
| 19 | FolderPlusIcon, | 21 | FolderPlusIcon, |
| 22 | LinkIcon, | ||
| 20 | MessageSquareIcon, | 23 | MessageSquareIcon, |
| 21 | SettingsIcon, | 24 | SettingsIcon, |
| 22 | SquarePenIcon, | 25 | SquarePenIcon, |
| ... | @@ -89,6 +92,7 @@ import { | ... | @@ -89,6 +92,7 @@ import { |
| 89 | } from "./CommandPalette.logic"; | 92 | } from "./CommandPalette.logic"; |
| 90 | import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; | 93 | import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; |
| 91 | import { CommandPaletteResults } from "./CommandPaletteResults"; | 94 | import { CommandPaletteResults } from "./CommandPaletteResults"; |
| 95 | import { GitHubIcon, GitLabIcon } from "./Icons"; | ||
| 92 | import { ProjectFavicon } from "./ProjectFavicon"; | 96 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 93 | import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; | 97 | import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; |
| 94 | import { useServerKeybindings } from "../rpc/serverState"; | 98 | import { useServerKeybindings } from "../rpc/serverState"; |
| ... | @@ -139,6 +143,70 @@ interface AddProjectEnvironmentOption { | ... | @@ -139,6 +143,70 @@ interface AddProjectEnvironmentOption { |
| 139 | readonly isPrimary: boolean; | 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 | export function CommandPalette({ children }: { children: ReactNode }) { | 210 | export function CommandPalette({ children }: { children: ReactNode }) { |
| 143 | const open = useCommandPaletteStore((store) => store.open); | 211 | const open = useCommandPaletteStore((store) => store.open); |
| 144 | const setOpen = useCommandPaletteStore((store) => store.setOpen); | 212 | const setOpen = useCommandPaletteStore((store) => store.setOpen); |
| ... | @@ -227,6 +295,9 @@ function OpenCommandPaletteDialog() { | ... | @@ -227,6 +295,9 @@ function OpenCommandPaletteDialog() { |
| 227 | null, | 295 | null, |
| 228 | ); | 296 | ); |
| 229 | const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false); | 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 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 301 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 231 | const primaryEnvironmentLabel = readPrimaryEnvironmentDescriptor()?.label ?? null; | 302 | const primaryEnvironmentLabel = readPrimaryEnvironmentDescriptor()?.label ?? null; |
| 232 | const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((state) => state.byId); | 303 | const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((state) => state.byId); |
| ... | @@ -295,7 +366,10 @@ function OpenCommandPaletteDialog() { | ... | @@ -295,7 +366,10 @@ function OpenCommandPaletteDialog() { |
| 295 | : null; | 366 | : null; |
| 296 | return getEnvironmentBrowsePlatform(os); | 367 | return getEnvironmentBrowsePlatform(os); |
| 297 | }, [browseEnvironmentId, primaryEnvironmentId, savedEnvironmentRuntimeById]); | 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 | const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); | 373 | const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); |
| 300 | const getAddProjectInitialQueryForEnvironment = useCallback( | 374 | const getAddProjectInitialQueryForEnvironment = useCallback( |
| 301 | (environmentId: EnvironmentId | null): string => { | 375 | (environmentId: EnvironmentId | null): string => { |
| ... | @@ -541,6 +615,7 @@ function OpenCommandPaletteDialog() { | ... | @@ -541,6 +615,7 @@ function OpenCommandPaletteDialog() { |
| 541 | } | 615 | } |
| 542 | 616 | ||
| 543 | function popView(): void { | 617 | function popView(): void { |
| 618 | setAddProjectCloneFlow(null); | ||
| 544 | if (viewStack.length <= 1) { | 619 | if (viewStack.length <= 1) { |
| 545 | setAddProjectEnvironmentId(null); | 620 | setAddProjectEnvironmentId(null); |
| 546 | } | 621 | } |
| ... | @@ -560,6 +635,7 @@ function OpenCommandPaletteDialog() { | ... | @@ -560,6 +635,7 @@ function OpenCommandPaletteDialog() { |
| 560 | const startAddProjectBrowse = useCallback( | 635 | const startAddProjectBrowse = useCallback( |
| 561 | (environmentId: EnvironmentId): void => { | 636 | (environmentId: EnvironmentId): void => { |
| 562 | setAddProjectEnvironmentId(environmentId); | 637 | setAddProjectEnvironmentId(environmentId); |
| 638 | setAddProjectCloneFlow(null); | ||
| 563 | pushPaletteView({ | 639 | pushPaletteView({ |
| 564 | addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />, | 640 | addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />, |
| 565 | groups: [], | 641 | groups: [], |
| ... | @@ -569,6 +645,19 @@ function OpenCommandPaletteDialog() { | ... | @@ -569,6 +645,19 @@ function OpenCommandPaletteDialog() { |
| 569 | [getAddProjectInitialQueryForEnvironment], | 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 | const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( | 661 | const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( |
| 573 | (option) => ({ | 662 | (option) => ({ |
| 574 | kind: "action", | 663 | kind: "action", |
| ... | @@ -595,6 +684,156 @@ function OpenCommandPaletteDialog() { | ... | @@ -595,6 +684,156 @@ function OpenCommandPaletteDialog() { |
| 595 | [addProjectEnvironmentItems], | 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 | const openAddProjectFlow = useCallback(() => { | 837 | const openAddProjectFlow = useCallback(() => { |
| 599 | if (addProjectEnvironmentOptions.length > 1) { | 838 | if (addProjectEnvironmentOptions.length > 1) { |
| 600 | pushPaletteView({ | 839 | pushPaletteView({ |
| ... | @@ -674,29 +913,29 @@ function OpenCommandPaletteDialog() { | ... | @@ -674,29 +913,29 @@ function OpenCommandPaletteDialog() { |
| 674 | }); | 913 | }); |
| 675 | } | 914 | } |
| 676 | 915 | ||
| 677 | if (addProjectEnvironmentOptions.length > 1) { | 916 | actionItems.push({ |
| 678 | actionItems.push({ | 917 | kind: "submenu", |
| 679 | kind: "submenu", | 918 | value: "action:add-project", |
| 680 | value: "action:add-project", | 919 | searchTerms: [ |
| 681 | searchTerms: ["add project", "folder", "directory", "browse", "environment"], | 920 | "add project", |
| 682 | title: "Add project", | 921 | "folder", |
| 683 | icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, | 922 | "directory", |
| 684 | addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />, | 923 | "browse", |
| 685 | groups: addProjectEnvironmentGroups, | 924 | "clone", |
| 686 | }); | 925 | "remote", |
| 687 | } else { | 926 | "repository", |
| 688 | actionItems.push({ | 927 | "repo", |
| 689 | kind: "action", | 928 | "git", |
| 690 | value: "action:add-project", | 929 | "github", |
| 691 | searchTerms: ["add project", "folder", "directory", "browse"], | 930 | "gitlab", |
| 692 | title: "Add project", | 931 | "url", |
| 693 | icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, | 932 | "environment", |
| 694 | keepOpen: true, | 933 | ], |
| 695 | run: async () => { | 934 | title: "Add project", |
| 696 | openAddProjectFlow(); | 935 | icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, |
| 697 | }, | 936 | addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />, |
| 698 | }); | 937 | groups: addProjectSourceGroups, |
| 699 | } | 938 | }); |
| 700 | 939 | ||
| 701 | actionItems.push({ | 940 | actionItems.push({ |
| 702 | kind: "action", | 941 | kind: "action", |
| ... | @@ -820,6 +1059,137 @@ function OpenCommandPaletteDialog() { | ... | @@ -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 | function browseTo(name: string): void { | 1193 | function browseTo(name: string): void { |
| 824 | const nextQuery = appendBrowsePathSegment(query, name); | 1194 | const nextQuery = appendBrowsePathSegment(query, name); |
| 825 | setHighlightedItemValue(null); | 1195 | setHighlightedItemValue(null); |
| ... | @@ -858,13 +1228,38 @@ function OpenCommandPaletteDialog() { | ... | @@ -858,13 +1228,38 @@ function OpenCommandPaletteDialog() { |
| 858 | browseUp, | 1228 | browseUp, |
| 859 | browseTo, | 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; | 1244 | return { |
| 863 | if (isBrowsing) { | 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 | displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups; | 1257 | displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups; |
| 865 | } | 1258 | } |
| 866 | 1259 | ||
| 867 | const inputPlaceholder = getCommandPaletteInputPlaceholder(paletteMode); | 1260 | const inputPlaceholder = |
| 1261 | remoteProjectInputPlaceholder(addProjectCloneFlow) ?? | ||
| 1262 | getCommandPaletteInputPlaceholder(paletteMode); | ||
| 868 | const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; | 1263 | const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; |
| 869 | const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; | 1264 | const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; |
| 870 | const canSubmitBrowsePath = isBrowsing && !relativePathNeedsActiveProject; | 1265 | const canSubmitBrowsePath = isBrowsing && !relativePathNeedsActiveProject; |
| ... | @@ -876,8 +1271,25 @@ function OpenCommandPaletteDialog() { | ... | @@ -876,8 +1271,25 @@ function OpenCommandPaletteDialog() { |
| 876 | (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null); | 1271 | (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null); |
| 877 | const useMetaForMod = isMacPlatform(navigator.platform); | 1272 | const useMetaForMod = isMacPlatform(navigator.platform); |
| 878 | const submitModifierLabel = useMetaForMod ? "\u2318" : "Ctrl"; | 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 | const addShortcutLabel = hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"; | 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 | const fileManagerName = getLocalFileManagerName(navigator.platform); | 1293 | const fileManagerName = getLocalFileManagerName(navigator.platform); |
| 882 | const canOpenProjectFromFileManager = | 1294 | const canOpenProjectFromFileManager = |
| 883 | isBrowsing && | 1295 | isBrowsing && |
| ... | @@ -915,6 +1327,12 @@ function OpenCommandPaletteDialog() { | ... | @@ -915,6 +1327,12 @@ function OpenCommandPaletteDialog() { |
| 915 | } | 1327 | } |
| 916 | 1328 | ||
| 917 | function handleKeyDown(event: KeyboardEvent<HTMLInputElement>): void { | 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 | const shouldSubmitBrowsePath = | 1336 | const shouldSubmitBrowsePath = |
| 919 | canSubmitBrowsePath && | 1337 | canSubmitBrowsePath && |
| 920 | event.key === "Enter" && | 1338 | event.key === "Enter" && |
| ... | @@ -922,7 +1340,11 @@ function OpenCommandPaletteDialog() { | ... | @@ -922,7 +1340,11 @@ function OpenCommandPaletteDialog() { |
| 922 | 1340 | ||
| 923 | if (shouldSubmitBrowsePath) { | 1341 | if (shouldSubmitBrowsePath) { |
| 924 | event.preventDefault(); | 1342 | event.preventDefault(); |
| 925 | void handleAddProject(resolvedAddProjectPath); | 1343 | if (isCloneDestinationStep) { |
| 1344 | void submitAddProjectCloneFlow(resolvedAddProjectPath); | ||
| 1345 | } else { | ||
| 1346 | void handleAddProject(resolvedAddProjectPath); | ||
| 1347 | } | ||
| 926 | return; | 1348 | return; |
| 927 | } | 1349 | } |
| 928 | 1350 | ||
| ... | @@ -999,9 +1421,9 @@ function OpenCommandPaletteDialog() { | ... | @@ -999,9 +1421,9 @@ function OpenCommandPaletteDialog() { |
| 999 | }} | 1421 | }} |
| 1000 | > | 1422 | > |
| 1001 | <Command | 1423 | <Command |
| 1002 | key={`${viewStack.length}-${browseGeneration}-${isBrowsing}`} | 1424 | key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${addProjectCloneFlow?.step ?? "none"}`} |
| 1003 | aria-label="Command palette" | 1425 | aria-label="Command palette" |
| 1004 | autoHighlight={isBrowsing ? false : "always"} | 1426 | autoHighlight={isBrowsing || isRemoteProjectCloneFlow ? false : "always"} |
| 1005 | mode="none" | 1427 | mode="none" |
| 1006 | onItemHighlighted={(value) => { | 1428 | onItemHighlighted={(value) => { |
| 1007 | setHighlightedItemValue(typeof value === "string" ? value : null); | 1429 | setHighlightedItemValue(typeof value === "string" ? value : null); |
| ... | @@ -1011,7 +1433,15 @@ function OpenCommandPaletteDialog() { | ... | @@ -1011,7 +1433,15 @@ function OpenCommandPaletteDialog() { |
| 1011 | > | 1433 | > |
| 1012 | <div className="relative"> | 1434 | <div className="relative"> |
| 1013 | <CommandInput | 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 | placeholder={inputPlaceholder} | 1445 | placeholder={inputPlaceholder} |
| 1016 | wrapperClassName={ | 1446 | wrapperClassName={ |
| 1017 | isSubmenu ? "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto" : undefined | 1447 | isSubmenu ? "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto" : undefined |
| ... | @@ -1036,7 +1466,28 @@ function OpenCommandPaletteDialog() { | ... | @@ -1036,7 +1466,28 @@ function OpenCommandPaletteDialog() { |
| 1036 | : {})} | 1466 | : {})} |
| 1037 | onKeyDown={handleKeyDown} | 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 | <Button | 1491 | <Button |
| 1041 | variant="outline" | 1492 | variant="outline" |
| 1042 | size="xs" | 1493 | size="xs" |
| ... | @@ -1046,7 +1497,9 @@ function OpenCommandPaletteDialog() { | ... | @@ -1046,7 +1497,9 @@ function OpenCommandPaletteDialog() { |
| 1046 | hasHighlightedBrowseItem ? "gap-1" : "gap-1.5", | 1497 | hasHighlightedBrowseItem ? "gap-1" : "gap-1.5", |
| 1047 | )} | 1498 | )} |
| 1048 | aria-label={`${submitActionLabel} (${addShortcutLabel})`} | 1499 | aria-label={`${submitActionLabel} (${addShortcutLabel})`} |
| 1049 | disabled={relativePathNeedsActiveProject} | 1500 | disabled={ |
| 1501 | relativePathNeedsActiveProject || (isCloneDestinationStep && isRemoteProjectPending) | ||
| 1502 | } | ||
| 1050 | onMouseDown={(event) => { | 1503 | onMouseDown={(event) => { |
| 1051 | event.preventDefault(); | 1504 | event.preventDefault(); |
| 1052 | }} | 1505 | }} |
| ... | @@ -1054,11 +1507,17 @@ function OpenCommandPaletteDialog() { | ... | @@ -1054,11 +1507,17 @@ function OpenCommandPaletteDialog() { |
| 1054 | if (relativePathNeedsActiveProject) { | 1507 | if (relativePathNeedsActiveProject) { |
| 1055 | return; | 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 | title={`${submitActionLabel} (${addShortcutLabel})`} | 1516 | title={`${submitActionLabel} (${addShortcutLabel})`} |
| 1060 | > | 1517 | > |
| 1061 | <span>{submitActionLabel}</span> | 1518 | <span> |
| 1519 | {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel} | ||
| 1520 | </span> | ||
| 1062 | <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1"> | 1521 | <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1"> |
| 1063 | <Kbd>{hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"}</Kbd> | 1522 | <Kbd>{hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"}</Kbd> |
| 1064 | </KbdGroup> | 1523 | </KbdGroup> |
| ... | @@ -1066,19 +1525,47 @@ function OpenCommandPaletteDialog() { | ... | @@ -1066,19 +1525,47 @@ function OpenCommandPaletteDialog() { |
| 1066 | ) : null} | 1525 | ) : null} |
| 1067 | </div> | 1526 | </div> |
| 1068 | <CommandPanel className="max-h-[min(28rem,70vh)]"> | 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 | <CommandPaletteResults | 1546 | <CommandPaletteResults |
| 1070 | groups={displayedGroups} | 1547 | groups={displayedGroups} |
| 1071 | highlightedItemValue={highlightedItemValue} | 1548 | highlightedItemValue={highlightedItemValue} |
| 1072 | isActionsOnly={isActionsOnly} | 1549 | isActionsOnly={isActionsOnly} |
| 1073 | keybindings={keybindings} | 1550 | keybindings={keybindings} |
| 1074 | onExecuteItem={executeItem} | 1551 | onExecuteItem={executeItem} |
| 1075 | {...(relativePathNeedsActiveProject | 1552 | {...(addProjectCloneFlow?.step === "repository" |
| 1076 | ? { emptyStateMessage: "Relative paths require an active project." } | 1553 | ? { |
| 1077 | : willCreateProjectPath | 1554 | emptyStateMessage: |
| 1078 | ? { | 1555 | addProjectCloneFlow.source === "url" |
| 1079 | emptyStateMessage: "Press Enter to create this folder and add it as a project.", | 1556 | ? "Enter a Git clone URL and press Enter to continue." |
| 1080 | } | 1557 | : "Enter a repository path and press Enter to look it up.", |
| 1081 | : {})} | 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 | </CommandPanel> | 1570 | </CommandPanel> |
| 1084 | <CommandFooter className="gap-3 max-sm:flex-col max-sm:items-start"> | 1571 | <CommandFooter className="gap-3 max-sm:flex-col max-sm:items-start"> |
| ... | @@ -1092,7 +1579,14 @@ function OpenCommandPaletteDialog() { | ... | @@ -1092,7 +1579,14 @@ function OpenCommandPaletteDialog() { |
| 1092 | </Kbd> | 1579 | </Kbd> |
| 1093 | <span className={cn("text-muted-foreground/80")}>Navigate</span> | 1580 | <span className={cn("text-muted-foreground/80")}>Navigate</span> |
| 1094 | </KbdGroup> | 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 | <KbdGroup className="items-center gap-1.5"> | 1590 | <KbdGroup className="items-center gap-1.5"> |
| 1097 | <Kbd>Enter</Kbd> | 1591 | <Kbd>Enter</Kbd> |
| 1098 | <span className={cn("text-muted-foreground/80")}>Select</span> | 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,12 +100,14 @@ vi.mock("~/editorPreferences", () => ({ |
| 100 | vi.mock("~/lib/gitReactQuery", () => ({ | 100 | vi.mock("~/lib/gitReactQuery", () => ({ |
| 101 | gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })), | 101 | gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })), |
| 102 | gitMutationKeys: { | 102 | gitMutationKeys: { |
| 103 | publishRepository: vi.fn(() => ["publish-repository"]), | ||
| 103 | pull: vi.fn(() => ["pull"]), | 104 | pull: vi.fn(() => ["pull"]), |
| 104 | runStackedAction: vi.fn(() => ["run-stacked-action"]), | 105 | runStackedAction: vi.fn(() => ["run-stacked-action"]), |
| 105 | }, | 106 | }, |
| 106 | gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })), | 107 | gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })), |
| 107 | gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })), | 108 | gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })), |
| 108 | invalidateGitQueries: invalidateGitQueriesSpy, | 109 | invalidateGitQueries: invalidateGitQueriesSpy, |
| 110 | sourceControlPublishRepositoryMutationOptions: vi.fn(() => ({ __kind: "publish-repository" })), | ||
| 109 | })); | 111 | })); |
| 110 | 112 | ||
| 111 | vi.mock("~/lib/gitStatusState", () => ({ | 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,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 | const quick = resolveQuickAction( | 734 | const quick = resolveQuickAction( |
| 735 | status({ | 735 | status({ |
| 736 | hasUpstream: false, | 736 | hasUpstream: false, |
| ... | @@ -742,10 +742,9 @@ describe("when: ref has no upstream configured", () => { | ... | @@ -742,10 +742,9 @@ describe("when: ref has no upstream configured", () => { |
| 742 | false, | 742 | false, |
| 743 | ); | 743 | ); |
| 744 | assert.deepEqual(quick, { | 744 | assert.deepEqual(quick, { |
| 745 | kind: "show_hint", | 745 | kind: "open_publish", |
| 746 | label: "Push", | 746 | label: "Publish repository", |
| 747 | hint: 'Add an "origin" remote before pushing or creating a pull request.', | 747 | disabled: false, |
| 748 | disabled: true, | ||
| 749 | }); | 748 | }); |
| 750 | }); | 749 | }); |
| 751 | 750 | ||
| ... | @@ -779,7 +778,7 @@ describe("when: ref has no upstream configured", () => { | ... | @@ -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 | const items = buildMenuItems( | 782 | const items = buildMenuItems( |
| 784 | status({ hasUpstream: false, pr: null, aheadCount: 2 }), | 783 | status({ hasUpstream: false, pr: null, aheadCount: 2 }), |
| 785 | false, | 784 | false, |
| ... | @@ -794,22 +793,6 @@ describe("when: ref has no upstream configured", () => { | ... | @@ -794,22 +793,6 @@ describe("when: ref has no upstream configured", () => { |
| 794 | kind: "open_dialog", | 793 | kind: "open_dialog", |
| 795 | dialogAction: "commit", | 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,7 +26,7 @@ export interface GitActionMenuItem { |
| 26 | export interface GitQuickAction { | 26 | export interface GitQuickAction { |
| 27 | label: string; | 27 | label: string; |
| 28 | disabled: boolean; | 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 | action?: GitStackedAction; | 30 | action?: GitStackedAction; |
| 31 | hint?: string; | 31 | hint?: string; |
| 32 | } | 32 | } |
| ... | @@ -122,15 +122,21 @@ export function buildMenuItems( | ... | @@ -122,15 +122,21 @@ export function buildMenuItems( |
| 122 | (gitStatus.hasUpstream || canPushWithoutUpstream); | 122 | (gitStatus.hasUpstream || canPushWithoutUpstream); |
| 123 | const canOpenPr = !isBusy && hasOpenPr; | 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 | return [ | 138 | return [ |
| 126 | { | 139 | commitItem, |
| 127 | id: "commit", | ||
| 128 | label: "Commit", | ||
| 129 | disabled: !canCommit, | ||
| 130 | icon: "commit", | ||
| 131 | kind: "open_dialog", | ||
| 132 | dialogAction: "commit", | ||
| 133 | }, | ||
| 134 | { | 140 | { |
| 135 | id: "push", | 141 | id: "push", |
| 136 | label: "Push", | 142 | label: "Push", |
| ... | @@ -216,10 +222,9 @@ export function resolveQuickAction( | ... | @@ -216,10 +222,9 @@ export function resolveQuickAction( |
| 216 | return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; | 222 | return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; |
| 217 | } | 223 | } |
| 218 | return { | 224 | return { |
| 219 | label: "Push", | 225 | label: "Publish repository", |
| 220 | disabled: true, | 226 | disabled: false, |
| 221 | kind: "show_hint", | 227 | kind: "open_publish", |
| 222 | hint: `Add an "origin" remote before pushing or creating a ${terminology.singular}.`, | ||
| 223 | }; | 228 | }; |
| 224 | } | 229 | } |
| 225 | if (!isAhead) { | 230 | if (!isAhead) { |
apps/web/src/components/GitActionsControl.tsx+550-2| ... | @@ -3,11 +3,30 @@ import type { | ... | @@ -3,11 +3,30 @@ import type { |
| 3 | GitActionProgressEvent, | 3 | GitActionProgressEvent, |
| 4 | GitRunStackedActionResult, | 4 | GitRunStackedActionResult, |
| 5 | GitStackedAction, | 5 | GitStackedAction, |
| 6 | SourceControlCloneProtocol, | ||
| 7 | SourceControlProviderKind, | ||
| 8 | SourceControlPublishRepositoryResult, | ||
| 9 | SourceControlRepositoryVisibility, | ||
| 6 | VcsStatusResult, | 10 | VcsStatusResult, |
| 7 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 8 | import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; | 12 | import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; |
| 13 | import { Option } from "effect"; | ||
| 9 | import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; | 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 | import { | 30 | import { |
| 12 | buildGitActionProgressStages, | 31 | buildGitActionProgressStages, |
| 13 | buildMenuItems, | 32 | buildMenuItems, |
| ... | @@ -33,9 +52,18 @@ import { | ... | @@ -33,9 +52,18 @@ import { |
| 33 | DialogTitle, | 52 | DialogTitle, |
| 34 | } from "~/components/ui/dialog"; | 53 | } from "~/components/ui/dialog"; |
| 35 | import { Group, GroupSeparator } from "~/components/ui/group"; | 54 | import { Group, GroupSeparator } from "~/components/ui/group"; |
| 55 | import { Input } from "~/components/ui/input"; | ||
| 36 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; | 56 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; |
| 37 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; | 57 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; |
| 38 | import { ScrollArea } from "~/components/ui/scroll-area"; | 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 | import { Textarea } from "~/components/ui/textarea"; | 67 | import { Textarea } from "~/components/ui/textarea"; |
| 40 | import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast"; | 68 | import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast"; |
| 41 | import { openInPreferredEditor } from "~/editorPreferences"; | 69 | import { openInPreferredEditor } from "~/editorPreferences"; |
| ... | @@ -44,8 +72,10 @@ import { | ... | @@ -44,8 +72,10 @@ import { |
| 44 | gitMutationKeys, | 72 | gitMutationKeys, |
| 45 | gitPullMutationOptions, | 73 | gitPullMutationOptions, |
| 46 | gitRunStackedActionMutationOptions, | 74 | gitRunStackedActionMutationOptions, |
| 75 | sourceControlPublishRepositoryMutationOptions, | ||
| 47 | } from "~/lib/gitReactQuery"; | 76 | } from "~/lib/gitReactQuery"; |
| 48 | import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; | 77 | import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; |
| 78 | import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState"; | ||
| 49 | import { newCommandId, randomUUID } from "~/lib/utils"; | 79 | import { newCommandId, randomUUID } from "~/lib/utils"; |
| 50 | import { resolvePathLinkTarget } from "~/terminal-links"; | 80 | import { resolvePathLinkTarget } from "~/terminal-links"; |
| 51 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; | 81 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; |
| ... | @@ -70,6 +100,8 @@ interface PendingDefaultBranchAction { | ... | @@ -70,6 +100,8 @@ interface PendingDefaultBranchAction { |
| 70 | filePaths?: string[]; | 100 | filePaths?: string[]; |
| 71 | } | 101 | } |
| 72 | 102 | ||
| 103 | type PublishProviderKind = Extract<SourceControlProviderKind, "github" | "gitlab">; | ||
| 104 | |||
| 73 | type GitActionToastId = ReturnType<typeof toastManager.add>; | 105 | type GitActionToastId = ReturnType<typeof toastManager.add>; |
| 74 | 106 | ||
| 75 | interface ActiveGitActionProgress { | 107 | interface ActiveGitActionProgress { |
| ... | @@ -211,6 +243,7 @@ function GitQuickActionIcon({ | ... | @@ -211,6 +243,7 @@ function GitQuickActionIcon({ |
| 211 | }) { | 243 | }) { |
| 212 | const iconClassName = "size-3.5"; | 244 | const iconClassName = "size-3.5"; |
| 213 | if (quickAction.kind === "open_pr") return <SourceControlIcon className={iconClassName} />; | 245 | if (quickAction.kind === "open_pr") return <SourceControlIcon className={iconClassName} />; |
| 246 | if (quickAction.kind === "open_publish") return <CloudUploadIcon className={iconClassName} />; | ||
| 214 | if (quickAction.kind === "run_pull") return <InfoIcon className={iconClassName} />; | 247 | if (quickAction.kind === "run_pull") return <InfoIcon className={iconClassName} />; |
| 215 | if (quickAction.kind === "run_action") { | 248 | if (quickAction.kind === "run_action") { |
| 216 | if (quickAction.action === "commit") return <GitCommitIcon className={iconClassName} />; | 249 | if (quickAction.action === "commit") return <GitCommitIcon className={iconClassName} />; |
| ... | @@ -252,8 +285,35 @@ export default function GitActionsControl({ | ... | @@ -252,8 +285,35 @@ export default function GitActionsControl({ |
| 252 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); | 285 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); |
| 253 | const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set()); | 286 | const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set()); |
| 254 | const [isEditingFiles, setIsEditingFiles] = useState(false); | 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 | const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = | 302 | const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = |
| 256 | useState<PendingDefaultBranchAction | null>(null); | 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 | const activeGitActionProgressRef = useRef<ActiveGitActionProgress | null>(null); | 317 | const activeGitActionProgressRef = useRef<ActiveGitActionProgress | null>(null); |
| 258 | let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise<void>; | 318 | let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise<void>; |
| 259 | 319 | ||
| ... | @@ -365,6 +425,13 @@ export default function GitActionsControl({ | ... | @@ -365,6 +425,13 @@ export default function GitActionsControl({ |
| 365 | const pullMutation = useMutation( | 425 | const pullMutation = useMutation( |
| 366 | gitPullMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), | 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 | const isRunStackedActionRunning = | 436 | const isRunStackedActionRunning = |
| 370 | useIsMutating({ | 437 | useIsMutating({ |
| ... | @@ -372,7 +439,11 @@ export default function GitActionsControl({ | ... | @@ -372,7 +439,11 @@ export default function GitActionsControl({ |
| 372 | }) > 0; | 439 | }) > 0; |
| 373 | const isPullRunning = | 440 | const isPullRunning = |
| 374 | useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0; | 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 | const isSelectingWorktreeBase = | 447 | const isSelectingWorktreeBase = |
| 377 | !activeServerThread && | 448 | !activeServerThread && |
| 378 | activeDraftThread?.envMode === "worktree" && | 449 | activeDraftThread?.envMode === "worktree" && |
| ... | @@ -439,6 +510,13 @@ export default function GitActionsControl({ | ... | @@ -439,6 +510,13 @@ export default function GitActionsControl({ |
| 439 | }; | 510 | }; |
| 440 | }, [updateActiveProgressToast]); | 511 | }, [updateActiveProgressToast]); |
| 441 | 512 | ||
| 513 | useEffect(() => { | ||
| 514 | if (!isPublishDialogOpen || hasUserEditedPublishRepository) { | ||
| 515 | return; | ||
| 516 | } | ||
| 517 | setPublishRepository(publishRepositoryPrefill); | ||
| 518 | }, [isPublishDialogOpen, hasUserEditedPublishRepository, publishRepositoryPrefill]); | ||
| 519 | |||
| 442 | useEffect(() => { | 520 | useEffect(() => { |
| 443 | if (gitCwd === null) { | 521 | if (gitCwd === null) { |
| 444 | return; | 522 | return; |
| ... | @@ -785,6 +863,10 @@ export default function GitActionsControl({ | ... | @@ -785,6 +863,10 @@ export default function GitActionsControl({ |
| 785 | void openExistingPr(); | 863 | void openExistingPr(); |
| 786 | return; | 864 | return; |
| 787 | } | 865 | } |
| 866 | if (quickAction.kind === "open_publish") { | ||
| 867 | setIsPublishDialogOpen(true); | ||
| 868 | return; | ||
| 869 | } | ||
| 788 | if (quickAction.kind === "run_pull") { | 870 | if (quickAction.kind === "run_pull") { |
| 789 | const promise = pullMutation.mutateAsync(); | 871 | const promise = pullMutation.mutateAsync(); |
| 790 | void toastManager.promise< | 872 | void toastManager.promise< |
| ... | @@ -882,6 +964,47 @@ export default function GitActionsControl({ | ... | @@ -882,6 +964,47 @@ export default function GitActionsControl({ |
| 882 | [gitCwd, threadToastData], | 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 | if (!gitCwd) return null; | 1008 | if (!gitCwd) return null; |
| 886 | 1009 | ||
| 887 | return ( | 1010 | return ( |
| ... | @@ -996,6 +1119,17 @@ export default function GitActionsControl({ | ... | @@ -996,6 +1119,17 @@ export default function GitActionsControl({ |
| 996 | </MenuItem> | 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 | {gitStatusForActions?.refName === null && ( | 1133 | {gitStatusForActions?.refName === null && ( |
| 1000 | <p className="px-2 py-1.5 text-xs text-warning"> | 1134 | <p className="px-2 py-1.5 text-xs text-warning"> |
| 1001 | Detached HEAD: create and checkout a refName to enable push and pull request | 1135 | Detached HEAD: create and checkout a refName to enable push and pull request |
| ... | @@ -1188,6 +1322,420 @@ export default function GitActionsControl({ | ... | @@ -1188,6 +1322,420 @@ export default function GitActionsControl({ |
| 1188 | </DialogPopup> | 1322 | </DialogPopup> |
| 1189 | </Dialog> | 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 | <Dialog | 1739 | <Dialog |
| 1192 | open={pendingDefaultBranchAction !== null} | 1740 | open={pendingDefaultBranchAction !== null} |
| 1193 | onOpenChange={(open) => { | 1741 | onOpenChange={(open) => { |
apps/web/src/components/settings/SourceControlSettings.tsx+9-2| ... | @@ -57,6 +57,8 @@ const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = { | ... | @@ -57,6 +57,8 @@ const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = { |
| 57 | jj: JujutsuIcon, | 57 | jj: JujutsuIcon, |
| 58 | }; | 58 | }; |
| 59 | 59 | ||
| 60 | const SOURCE_CONTROL_SKELETON_ROWS = ["primary", "secondary"] as const; | ||
| 61 | |||
| 60 | function optionLabel(value: Option.Option<string>): string | null { | 62 | function optionLabel(value: Option.Option<string>): string | null { |
| 61 | return Option.getOrNull(value); | 63 | return Option.getOrNull(value); |
| 62 | } | 64 | } |
| ... | @@ -157,6 +159,11 @@ function itemSummary({ | ... | @@ -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 | if (auth.status === "unauthenticated") { | 167 | if (auth.status === "unauthenticated") { |
| 161 | return <span>Sign in with the {item.executable} CLI to enable pull request actions.</span>; | 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,8 +239,8 @@ function SourceControlSectionSkeleton({ |
| 232 | }) { | 239 | }) { |
| 233 | return ( | 240 | return ( |
| 234 | <SettingsSection title={title} headerAction={headerAction}> | 241 | <SettingsSection title={title} headerAction={headerAction}> |
| 235 | {Array.from({ length: 2 }, (_, index) => ( | 242 | {SOURCE_CONTROL_SKELETON_ROWS.map((row) => ( |
| 236 | <div key={index} className="border-t border-border/60 px-4 py-3.5 first:border-t-0 sm:px-5"> | 243 | <div key={row} className="border-t border-border/60 px-4 py-3.5 first:border-t-0 sm:px-5"> |
| 237 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> | 244 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
| 238 | <div className="min-w-0 flex-1 space-y-2"> | 245 | <div className="min-w-0 flex-1 space-y-2"> |
| 239 | <div className="flex items-center gap-2"> | 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,6 +23,11 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { |
| 23 | filesystem: { | 23 | filesystem: { |
| 24 | browse: rpcClient.filesystem.browse, | 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 | vcs: { | 31 | vcs: { |
| 27 | pull: rpcClient.vcs.pull, | 32 | pull: rpcClient.vcs.pull, |
| 28 | refreshStatus: rpcClient.vcs.refreshStatus, | 33 | refreshStatus: rpcClient.vcs.refreshStatus, |
apps/web/src/lib/gitReactQuery.ts+23| ... | @@ -2,6 +2,7 @@ import { | ... | @@ -2,6 +2,7 @@ import { |
| 2 | type EnvironmentId, | 2 | type EnvironmentId, |
| 3 | type GitActionProgressEvent, | 3 | type GitActionProgressEvent, |
| 4 | type GitStackedAction, | 4 | type GitStackedAction, |
| 5 | type SourceControlPublishRepositoryInput, | ||
| 5 | type ThreadId, | 6 | type ThreadId, |
| 6 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 7 | import { | 8 | import { |
| ... | @@ -36,6 +37,8 @@ export const gitMutationKeys = { | ... | @@ -36,6 +37,8 @@ export const gitMutationKeys = { |
| 36 | ["git", "mutation", "pull", environmentId ?? null, cwd] as const, | 37 | ["git", "mutation", "pull", environmentId ?? null, cwd] as const, |
| 37 | preparePullRequestThread: (environmentId: EnvironmentId | null, cwd: string | null) => | 38 | preparePullRequestThread: (environmentId: EnvironmentId | null, cwd: string | null) => |
| 38 | ["git", "mutation", "prepare-pull-request-thread", environmentId ?? null, cwd] as const, | 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 | export function invalidateGitQueries( | 44 | export function invalidateGitQueries( |
| ... | @@ -228,6 +231,26 @@ export function gitPullMutationOptions(input: { | ... | @@ -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 | * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. | 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,6 +56,11 @@ const rpcClientMock = { |
| 56 | filesystem: { | 56 | filesystem: { |
| 57 | browse: vi.fn(), | 57 | browse: vi.fn(), |
| 58 | }, | 58 | }, |
| 59 | sourceControl: { | ||
| 60 | lookupRepository: vi.fn(), | ||
| 61 | cloneRepository: vi.fn(), | ||
| 62 | publishRepository: vi.fn(), | ||
| 63 | }, | ||
| 59 | shell: { | 64 | shell: { |
| 60 | openInEditor: vi.fn(), | 65 | openInEditor: vi.fn(), |
| 61 | }, | 66 | }, |
apps/web/src/rpc/wsRpcClient.ts+13| ... | @@ -71,6 +71,11 @@ export interface WsRpcClient { | ... | @@ -71,6 +71,11 @@ export interface WsRpcClient { |
| 71 | readonly filesystem: { | 71 | readonly filesystem: { |
| 72 | readonly browse: RpcUnaryMethod<typeof WS_METHODS.filesystemBrowse>; | 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 | readonly shell: { | 79 | readonly shell: { |
| 75 | readonly openInEditor: (input: { | 80 | readonly openInEditor: (input: { |
| 76 | readonly cwd: Parameters<LocalApi["shell"]["openInEditor"]>[0]; | 81 | readonly cwd: Parameters<LocalApi["shell"]["openInEditor"]>[0]; |
| ... | @@ -165,6 +170,14 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { | ... | @@ -165,6 +170,14 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { |
| 165 | filesystem: { | 170 | filesystem: { |
| 166 | browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)), | 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 | shell: { | 181 | shell: { |
| 169 | openInEditor: (input) => | 182 | openInEditor: (input) => |
| 170 | transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), | 183 | transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), |
packages/contracts/src/ipc.ts+20-1| ... | @@ -55,7 +55,15 @@ import type { | ... | @@ -55,7 +55,15 @@ import type { |
| 55 | import type { EnvironmentId } from "./baseSchemas.ts"; | 55 | import type { EnvironmentId } from "./baseSchemas.ts"; |
| 56 | import { EditorId } from "./editor.ts"; | 56 | import { EditorId } from "./editor.ts"; |
| 57 | import { ServerSettings, type ClientSettings, type ServerSettingsPatch } from "./settings.ts"; | 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 | export interface ContextMenuItem<T extends string = string> { | 68 | export interface ContextMenuItem<T extends string = string> { |
| 61 | id: T; | 69 | id: T; |
| ... | @@ -258,6 +266,17 @@ export interface EnvironmentApi { | ... | @@ -258,6 +266,17 @@ export interface EnvironmentApi { |
| 258 | filesystem: { | 266 | filesystem: { |
| 259 | browse: (input: FilesystemBrowseInput) => Promise<FilesystemBrowseResult>; | 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 | vcs: { | 280 | vcs: { |
| 262 | listRefs: (input: VcsListRefsInput) => Promise<VcsListRefsResult>; | 281 | listRefs: (input: VcsListRefsInput) => Promise<VcsListRefsResult>; |
| 263 | createWorktree: (input: VcsCreateWorktreeInput) => Promise<VcsCreateWorktreeResult>; | 282 | createWorktree: (input: VcsCreateWorktreeInput) => Promise<VcsCreateWorktreeResult>; |
packages/contracts/src/rpc.ts+42-1| ... | @@ -77,7 +77,16 @@ import { | ... | @@ -77,7 +77,16 @@ import { |
| 77 | ServerUpsertKeybindingResult, | 77 | ServerUpsertKeybindingResult, |
| 78 | } from "./server.ts"; | 78 | } from "./server.ts"; |
| 79 | import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; | 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 | import { VcsError } from "./vcs.ts"; | 90 | import { VcsError } from "./vcs.ts"; |
| 82 | 91 | ||
| 83 | export const WS_METHODS = { | 92 | export const WS_METHODS = { |
| ... | @@ -125,6 +134,11 @@ export const WS_METHODS = { | ... | @@ -125,6 +134,11 @@ export const WS_METHODS = { |
| 125 | serverUpdateSettings: "server.updateSettings", | 134 | serverUpdateSettings: "server.updateSettings", |
| 126 | serverDiscoverSourceControl: "server.discoverSourceControl", | 135 | serverDiscoverSourceControl: "server.discoverSourceControl", |
| 127 | 136 | ||
| 137 | // Source control methods | ||
| 138 | sourceControlLookupRepository: "sourceControl.lookupRepository", | ||
| 139 | sourceControlCloneRepository: "sourceControl.cloneRepository", | ||
| 140 | sourceControlPublishRepository: "sourceControl.publishRepository", | ||
| 141 | |||
| 128 | // Streaming subscriptions | 142 | // Streaming subscriptions |
| 129 | subscribeVcsStatus: "subscribeVcsStatus", | 143 | subscribeVcsStatus: "subscribeVcsStatus", |
| 130 | subscribeTerminalEvents: "subscribeTerminalEvents", | 144 | subscribeTerminalEvents: "subscribeTerminalEvents", |
| ... | @@ -175,6 +189,30 @@ export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscov | ... | @@ -175,6 +189,30 @@ export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscov |
| 175 | success: SourceControlDiscoveryResult, | 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 | export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { | 216 | export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { |
| 179 | payload: ProjectSearchEntriesInput, | 217 | payload: ProjectSearchEntriesInput, |
| 180 | success: ProjectSearchEntriesResult, | 218 | success: ProjectSearchEntriesResult, |
| ... | @@ -381,6 +419,9 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -381,6 +419,9 @@ export const WsRpcGroup = RpcGroup.make( |
| 381 | WsServerGetSettingsRpc, | 419 | WsServerGetSettingsRpc, |
| 382 | WsServerUpdateSettingsRpc, | 420 | WsServerUpdateSettingsRpc, |
| 383 | WsServerDiscoverSourceControlRpc, | 421 | WsServerDiscoverSourceControlRpc, |
| 422 | WsSourceControlLookupRepositoryRpc, | ||
| 423 | WsSourceControlCloneRepositoryRpc, | ||
| 424 | WsSourceControlPublishRepositoryRpc, | ||
| 384 | WsProjectsSearchEntriesRpc, | 425 | WsProjectsSearchEntriesRpc, |
| 385 | WsProjectsWriteFileRpc, | 426 | WsProjectsWriteFileRpc, |
| 386 | WsShellOpenInEditorRpc, | 427 | WsShellOpenInEditorRpc, |
packages/contracts/src/sourceControl.ts+75-1| ... | @@ -43,6 +43,66 @@ export const SourceControlRepositoryCloneUrls = Schema.Struct({ | ... | @@ -43,6 +43,66 @@ export const SourceControlRepositoryCloneUrls = Schema.Struct({ |
| 43 | }); | 43 | }); |
| 44 | export type SourceControlRepositoryCloneUrls = typeof SourceControlRepositoryCloneUrls.Type; | 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 | export const SourceControlDiscoveryStatus = Schema.Literals(["available", "missing"]); | 106 | export const SourceControlDiscoveryStatus = Schema.Literals(["available", "missing"]); |
| 47 | export type SourceControlDiscoveryStatus = typeof SourceControlDiscoveryStatus.Type; | 107 | export type SourceControlDiscoveryStatus = typeof SourceControlDiscoveryStatus.Type; |
| 48 | 108 | ||
| ... | @@ -63,7 +123,7 @@ export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type; | ... | @@ -63,7 +123,7 @@ export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type; |
| 63 | 123 | ||
| 64 | const SourceControlDiscoveryItemFields = { | 124 | const SourceControlDiscoveryItemFields = { |
| 65 | label: TrimmedNonEmptyString, | 125 | label: TrimmedNonEmptyString, |
| 66 | executable: TrimmedNonEmptyString, | 126 | executable: Schema.optional(TrimmedNonEmptyString), |
| 67 | implemented: Schema.Boolean, | 127 | implemented: Schema.Boolean, |
| 68 | status: SourceControlDiscoveryStatus, | 128 | status: SourceControlDiscoveryStatus, |
| 69 | version: Schema.Option(TrimmedNonEmptyString), | 129 | version: Schema.Option(TrimmedNonEmptyString), |
| ... | @@ -109,3 +169,17 @@ export class SourceControlProviderError extends Schema.TaggedErrorClass<SourceCo | ... | @@ -109,3 +169,17 @@ export class SourceControlProviderError extends Schema.TaggedErrorClass<SourceCo |
| 109 | return `Source control provider ${this.provider} failed in ${this.operation}: ${this.detail}`; | 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 | } |