authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-05-03 18:05:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-03 18:05:46-07:00
logd7969264de66dbe0367083af83c19f87162b494c
tree13ec6282eb3556209d77a4230104331ba6ca5ca8
parentf7748a0d656f918080828130aceda039457897e0
signature Signed by PGP key B5690EEEBB952194

Add remote repository publish and discovery support (#2482)

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 = {}): {
580580 cwd: input.cwd,
581581 args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"],
582582 }).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 ),
583590 checkoutPullRequest: (input) =>
584591 execute({
585592 cwd: input.cwd,
apps/server/src/server.test.ts+10
......@@ -117,6 +117,10 @@ import {
117117} from "./vcs/VcsDriverRegistry.ts";
118118import { layer as VcsProvisioningServiceLayer } from "./vcs/VcsProvisioningService.ts";
119119import { layer as GitWorkflowServiceLayer } from "./git/GitWorkflowService.ts";
120import {
121 SourceControlRepositoryService,
122 type SourceControlRepositoryServiceShape,
123} from "./sourceControl/SourceControlRepositoryService.ts";
120124import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
121125import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";
122126
......@@ -330,6 +334,7 @@ const buildAppUnderTest = (options?: {
330334 vcsDriverRegistry?: Partial<VcsDriverRegistryShape>;
331335 gitVcsDriver?: Partial<GitVcsDriver.GitVcsDriverShape>;
332336 gitManager?: Partial<GitManagerShape>;
337 sourceControlRepositoryService?: Partial<SourceControlRepositoryServiceShape>;
333338 vcsStatusBroadcaster?: Partial<VcsStatusBroadcasterShape>;
334339 projectSetupScriptRunner?: Partial<ProjectSetupScriptRunnerShape>;
335340 terminalManager?: Partial<TerminalManagerShape>;
......@@ -545,6 +550,11 @@ const buildAppUnderTest = (options?: {
545550 Layer.provide(gitVcsDriverLayer),
546551 Layer.provide(gitWorkflowLayer),
547552 Layer.provide(vcsProvisioningLayer),
553 Layer.provide(
554 Layer.mock(SourceControlRepositoryService)({
555 ...options?.layers?.sourceControlRepositoryService,
556 }),
557 ),
548558 Layer.provideMerge(vcsStatusBroadcasterLayer),
549559 Layer.provide(
550560 Layer.mock(ProjectSetupScriptRunner)({
apps/server/src/server.ts+13-6
......@@ -54,6 +54,7 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
5454import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
5555import * as GitWorkflowService from "./git/GitWorkflowService.ts";
5656import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
57import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
5758import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts";
5859import { ObservabilityLive } from "./observability/Layers/Observability.ts";
5960import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts";
......@@ -161,15 +162,15 @@ const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe(
161162 Layer.provide(VcsProjectConfig.layer),
162163);
163164
165const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe(
166 Layer.provide(Layer.mergeAll(GitHubCli.layer, GitLabCli.layer)),
167 Layer.provideMerge(VcsDriverRegistryLayerLive),
168);
169
164170const GitManagerLayerLive = GitManager.layer.pipe(
165171 Layer.provideMerge(ProjectSetupScriptRunnerLive),
166172 Layer.provideMerge(GitVcsDriver.layer),
167 Layer.provideMerge(
168 SourceControlProviderRegistry.layer.pipe(
169 Layer.provide(Layer.mergeAll(GitHubCli.layer, GitLabCli.layer)),
170 Layer.provideMerge(VcsDriverRegistryLayerLive),
171 ),
172 ),
173 Layer.provideMerge(SourceControlProviderRegistryLayerLive),
173174 Layer.provideMerge(TextGeneration.layer),
174175);
175176
......@@ -183,11 +184,17 @@ const GitWorkflowLayerLive = GitWorkflowService.layer.pipe(
183184 Layer.provideMerge(GitLayerLive),
184185);
185186
187const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.layer.pipe(
188 Layer.provideMerge(GitVcsDriver.layer),
189 Layer.provideMerge(SourceControlProviderRegistryLayerLive),
190);
191
186192const VcsLayerLive = Layer.empty.pipe(
187193 Layer.provideMerge(VcsProjectConfig.layer),
188194 Layer.provideMerge(VcsDriverRegistryLayerLive),
189195 Layer.provideMerge(VcsProvisioningService.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))),
190196 Layer.provideMerge(GitWorkflowLayerLive),
197 Layer.provideMerge(SourceControlRepositoryServiceLayerLive),
191198 Layer.provideMerge(VcsStatusBroadcaster.layer.pipe(Layer.provide(GitWorkflowLayerLive))),
192199);
193200
apps/server/src/sourceControl/GitHubCli.test.ts+52
......@@ -211,6 +211,58 @@ describe("GitHubCli.layer", () => {
211211 }).pipe(Effect.provide(layer)),
212212 );
213213
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
214266 it.effect("surfaces a friendly error when the pull request is not found", () =>
215267 Effect.gen(function* () {
216268 mockRun.mockReturnValueOnce(
apps/server/src/sourceControl/GitHubCli.ts+57-1
......@@ -1,6 +1,10 @@
11import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect";
22
3import { TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts";
3import {
4 TrimmedNonEmptyString,
5 type SourceControlRepositoryVisibility,
6 type VcsError,
7} from "@t3tools/contracts";
48
59import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
610import {
......@@ -62,6 +66,12 @@ export interface GitHubCliShape {
6266 readonly repository: string;
6367 }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>;
6468
69 readonly createRepository: (input: {
70 readonly cwd: string;
71 readonly repository: string;
72 readonly visibility: SourceControlRepositoryVisibility;
73 }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>;
74
6575 readonly createPullRequest: (input: {
6676 readonly cwd: string;
6777 readonly baseBranch: string;
......@@ -160,6 +170,43 @@ function normalizeRepositoryCloneUrls(
160170 };
161171}
162172
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 */
179function 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
163210function decodeGitHubJson<S extends Schema.Top>(
164211 raw: string,
165212 schema: S,
......@@ -281,6 +328,15 @@ export const make = Effect.fn("makeGitHubCli")(function* () {
281328 ),
282329 Effect.map(normalizeRepositoryCloneUrls),
283330 ),
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 ),
284340 createPullRequest: (input) =>
285341 execute({
286342 cwd: input.cwd,
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+4
......@@ -130,6 +130,10 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () {
130130 github
131131 .getRepositoryCloneUrls(input)
132132 .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))),
133 createRepository: (input) =>
134 github
135 .createRepository(input)
136 .pipe(Effect.mapError((error) => providerError("createRepository", error))),
133137 getDefaultBranch: (input) =>
134138 github
135139 .getDefaultBranch(input)
apps/server/src/sourceControl/GitLabCli.test.ts+62-1
......@@ -175,7 +175,7 @@ layer("GitLabCli.layer", (it) => {
175175
176176 assert.deepStrictEqual(result, {
177177 nameWithOwner: "octocat/t3code",
178 url: "https://gitlab.com/octocat/t3code.git",
178 url: "https://gitlab.com/octocat/t3code",
179179 sshUrl: "git@gitlab.com:octocat/t3code.git",
180180 });
181181 }),
......@@ -217,6 +217,67 @@ layer("GitLabCli.layer", (it) => {
217217 }),
218218 );
219219
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
220281 it.effect("does not pass unsupported force flags when checking out merge requests", () =>
221282 Effect.gen(function* () {
222283 mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
apps/server/src/sourceControl/GitLabCli.ts+79-3
......@@ -1,6 +1,6 @@
11import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, type DateTime } from "effect";
22
3import { TrimmedNonEmptyString } from "@t3tools/contracts";
3import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts";
44
55import {
66 decodeGitLabMergeRequestJson,
......@@ -66,6 +66,12 @@ export interface GitLabCliShape {
6666 readonly repository: string;
6767 }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>;
6868
69 readonly createRepository: (input: {
70 readonly cwd: string;
71 readonly repository: string;
72 readonly visibility: SourceControlRepositoryVisibility;
73 }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>;
74
6975 readonly createMergeRequest: (input: {
7076 readonly cwd: string;
7177 readonly baseBranch: string;
......@@ -161,12 +167,16 @@ const RawGitLabDefaultBranchSchema = Schema.Struct({
161167 default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
162168});
163169
170const RawGitLabNamespaceSchema = Schema.Struct({
171 id: Schema.Number,
172});
173
164174function normalizeRepositoryCloneUrls(
165175 raw: Schema.Schema.Type<typeof RawGitLabRepositoryCloneUrlsSchema>,
166176): GitLabRepositoryCloneUrls {
167177 return {
168178 nameWithOwner: raw.path_with_namespace,
169 url: raw.http_url_to_repo || raw.web_url,
179 url: raw.web_url,
170180 sshUrl: raw.ssh_url_to_repo,
171181 };
172182}
......@@ -174,7 +184,7 @@ function normalizeRepositoryCloneUrls(
174184function decodeGitLabJson<S extends Schema.Top>(
175185 raw: string,
176186 schema: S,
177 operation: "getRepositoryCloneUrls" | "getDefaultBranch",
187 operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository",
178188 invalidDetail: string,
179189): Effect.Effect<S["Type"], GitLabCliError, S["DecodingServices"]> {
180190 return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
......@@ -228,6 +238,19 @@ function toSummaryWithOptionalUpdatedAt(
228238 return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary;
229239}
230240
241function 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
231254export const make = Effect.fn("makeGitLabCli")(function* () {
232255 const process = yield* VcsProcess;
233256
......@@ -320,6 +343,59 @@ export const make = Effect.fn("makeGitLabCli")(function* () {
320343 ),
321344 Effect.map(normalizeRepositoryCloneUrls),
322345 ),
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 },
323399 createMergeRequest: (input) => {
324400 const sourceProject = sourceProjectIdentifier(input.source);
325401 return execute({
apps/server/src/sourceControl/GitLabSourceControlProvider.ts+4
......@@ -92,6 +92,10 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () {
9292 gitlab
9393 .getRepositoryCloneUrls(input)
9494 .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))),
95 createRepository: (input) =>
96 gitlab
97 .createRepository(input)
98 .pipe(Effect.mapError((error) => providerError("createRepository", error))),
9599 getDefaultBranch: (input) =>
96100 gitlab
97101 .getDefaultBranch(input)
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+6-3
......@@ -115,6 +115,9 @@ Logged in to github.com account juliusmarminge (keyring)
115115 },
116116 ],
117117 );
118 const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket");
119 assert.ok(bitbucket);
120 assert.strictEqual("executable" in bitbucket, false);
118121 }).pipe(Effect.provide(testLayer));
119122});
120123
......@@ -204,9 +207,9 @@ Logged in as bitbucket-user
204207 },
205208 {
206209 kind: "bitbucket",
207 auth: "authenticated",
208 account: Option.some("bitbucket-user"),
209 detail: Option.none(),
210 auth: "unknown",
211 account: Option.none(),
212 detail: Option.some("Bitbucket provider support is not available yet."),
210213 },
211214 ],
212215 );
apps/server/src/sourceControl/SourceControlDiscovery.ts+44-50
......@@ -13,20 +13,22 @@ import * as VcsProcess from "../vcs/VcsProcess.ts";
1313
1414interface DiscoveryProbe {
1515 readonly label: string;
16 readonly executable: string;
17 readonly versionArgs: ReadonlyArray<string>;
16 readonly executable?: string;
17 readonly versionArgs?: ReadonlyArray<string>;
1818 readonly implemented: boolean;
1919 readonly installHint: string;
2020}
2121
2222type VcsProbe = DiscoveryProbe & {
2323 readonly kind: VcsDriverKind;
24 readonly executable: string;
25 readonly versionArgs: ReadonlyArray<string>;
2426};
2527
2628type ProviderProbe = DiscoveryProbe & {
2729 readonly kind: SourceControlProviderKind;
28 readonly authArgs: ReadonlyArray<string>;
29 readonly parseAuth: (input: AuthProbeInput) => SourceControlProviderAuth;
30 readonly authArgs?: ReadonlyArray<string>;
31 readonly parseAuth?: (input: AuthProbeInput) => SourceControlProviderAuth;
3032};
3133
3234interface AuthProbeInput {
......@@ -38,7 +40,7 @@ interface AuthProbeInput {
3840interface DiscoveryProbeResult<Kind extends string> {
3941 readonly kind: Kind;
4042 readonly label: string;
41 readonly executable: string;
43 readonly executable?: string;
4244 readonly implemented: boolean;
4345 readonly status: "available" | "missing";
4446 readonly version: Option.Option<string>;
......@@ -101,12 +103,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [
101103 {
102104 kind: "bitbucket",
103105 label: "Bitbucket",
104 executable: "bb",
105 versionArgs: ["--version"],
106 authArgs: ["auth", "status"],
107 parseAuth: parseBitbucketAuth,
108106 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.",
110108 },
111109];
112110
......@@ -266,37 +264,6 @@ function parseAzureAuth(input: AuthProbeInput): SourceControlProviderAuth {
266264 });
267265}
268266
269function 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
300267export interface SourceControlDiscoveryShape {
301268 readonly discover: Effect.Effect<SourceControlDiscoveryResult>;
302269}
......@@ -314,12 +281,27 @@ export const layer = Layer.effect(
314281
315282 const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>(
316283 input: DiscoveryProbe & { readonly kind: Kind },
317 ): Effect.Effect<DiscoveryProbeResult<Kind>> =>
318 process
284 ): Effect.Effect<DiscoveryProbeResult<Kind>> => {
285 const executable = input.executable;
286 const versionArgs = input.versionArgs;
287
288 if (!executable || !versionArgs) {
289 return Effect.succeed({
290 kind: input.kind,
291 label: input.label,
292 implemented: input.implemented,
293 status: "missing" as const,
294 version: Option.none<string>(),
295 installHint: input.installHint,
296 detail: Option.some(input.installHint),
297 } satisfies DiscoveryProbeResult<Kind>);
298 }
299
300 return process
319301 .run({
320302 operation: "source-control.discovery.probe",
321 command: input.executable,
322 args: input.versionArgs,
303 command: executable,
304 args: versionArgs,
323305 cwd: config.cwd,
324306 timeoutMs: 5_000,
325307 maxOutputBytes: 8_000,
......@@ -331,7 +313,7 @@ export const layer = Layer.effect(
331313 ({
332314 kind: input.kind,
333315 label: input.label,
334 executable: input.executable,
316 executable,
335317 implemented: input.implemented,
336318 status: "available" as const,
337319 version: Option.orElse(firstNonEmptyLine(result.stdout), () =>
......@@ -345,7 +327,7 @@ export const layer = Layer.effect(
345327 Effect.succeed({
346328 kind: input.kind,
347329 label: input.label,
348 executable: input.executable,
330 executable,
349331 implemented: input.implemented,
350332 status: "missing" as const,
351333 version: Option.none<string>(),
......@@ -354,10 +336,22 @@ export const layer = Layer.effect(
354336 } satisfies DiscoveryProbeResult<Kind>),
355337 ),
356338 );
339 };
357340
358341 const probeProvider = (input: ProviderProbe) =>
359342 probe(input).pipe(
360343 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
361355 if (item.status !== "available") {
362356 return Effect.succeed({
363357 ...item,
......@@ -368,8 +362,8 @@ export const layer = Layer.effect(
368362 return process
369363 .run({
370364 operation: "source-control.discovery.auth",
371 command: input.executable,
372 args: input.authArgs,
365 command: executable,
366 args: authArgs,
373367 cwd: config.cwd,
374368 allowNonZeroExit: true,
375369 timeoutMs: 5_000,
......@@ -381,7 +375,7 @@ export const layer = Layer.effect(
381375 (result) =>
382376 ({
383377 ...item,
384 auth: input.parseAuth(result),
378 auth: parseAuth(result),
385379 }) satisfies SourceControlProviderDiscoveryItem,
386380 ),
387381 Effect.catch((cause) =>
apps/server/src/sourceControl/SourceControlProvider.ts+6
......@@ -6,6 +6,7 @@ import type {
66 SourceControlProviderInfo,
77 SourceControlProviderKind,
88 SourceControlRepositoryCloneUrls,
9 SourceControlRepositoryVisibility,
910} from "@t3tools/contracts";
1011
1112export interface SourceControlProviderContext {
......@@ -50,6 +51,11 @@ export interface SourceControlProviderShape {
5051 readonly context?: SourceControlProviderContext;
5152 readonly repository: string;
5253 }) => 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>;
5359 readonly getDefaultBranch: (input: {
5460 readonly cwd: string;
5561 readonly context?: SourceControlProviderContext;
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+1
......@@ -58,6 +58,7 @@ function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProv
5858 getChangeRequest: () => unsupported("getChangeRequest"),
5959 createChangeRequest: () => unsupported("createChangeRequest"),
6060 getRepositoryCloneUrls: () => unsupported("getRepositoryCloneUrls"),
61 createRepository: () => unsupported("createRepository"),
6162 getDefaultBranch: () => unsupported("getDefaultBranch"),
6263 checkoutChangeRequest: () => unsupported("checkoutChangeRequest"),
6364 });
apps/server/src/sourceControl/SourceControlRepositoryService.test.ts created+322
......@@ -0,0 +1,322 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import { Effect, FileSystem, Layer } from "effect";
4import { ChildProcessSpawner } from "effect/unstable/process";
5
6import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts";
7
8import { ServerConfig } from "../config.ts";
9import {
10 GitVcsDriver,
11 type ExecuteGitResult,
12 type GitVcsDriverShape,
13} from "../vcs/GitVcsDriver.ts";
14import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts";
15import type { SourceControlProviderShape } from "./SourceControlProvider.ts";
16import { SourceControlRepositoryService, layer } from "./SourceControlRepositoryService.ts";
17
18const CLONE_URLS = {
19 nameWithOwner: "octocat/t3code",
20 url: "https://github.com/octocat/t3code",
21 sshUrl: "git@github.com:octocat/t3code.git",
22};
23
24function 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
46function processOutput(): ExecuteGitResult {
47 return {
48 exitCode: ChildProcessSpawner.ExitCode(0),
49 stdout: "",
50 stderr: "",
51 stdoutTruncated: false,
52 stderrTruncated: false,
53 };
54}
55
56function 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
85it.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
108it.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
153it.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
221it.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
258it.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 @@
1import OS from "node:os";
2import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect";
3
4import {
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
17import { ServerConfig } from "../config.ts";
18import { GitVcsDriver } from "../vcs/GitVcsDriver.ts";
19import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts";
20
21export 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
33export class SourceControlRepositoryService extends Context.Service<
34 SourceControlRepositoryService,
35 SourceControlRepositoryServiceShape
36>()("t3/source-control/SourceControlRepositoryService") {}
37
38function 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
51function 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
65function 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
78function 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
90function 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
103function 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
113export 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
320export const layer = Layer.effect(SourceControlRepositoryService, make());
apps/server/src/vcs/GitVcsDriver.ts+1
......@@ -165,6 +165,7 @@ export interface GitVcsDriverShape {
165165 readonly pushCurrentBranch: (
166166 cwd: string,
167167 fallbackBranch: string | null,
168 options?: { readonly remoteName?: string | null },
168169 ) => Effect.Effect<GitPushResult, GitCommandError>;
169170 readonly readRangeContext: (
170171 cwd: string,
apps/server/src/vcs/GitVcsDriverCore.test.ts+36
......@@ -326,5 +326,41 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
326326 assert.notEqual(badBranch.exitCode, 0);
327327 }),
328328 );
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 );
329365 });
330366});
apps/server/src/vcs/GitVcsDriverCore.ts+18-1
......@@ -1431,7 +1431,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
14311431 });
14321432
14331433 const pushCurrentBranch: GitVcsDriverShape["pushCurrentBranch"] = Effect.fn("pushCurrentBranch")(
1434 function* (cwd, fallbackBranch) {
1434 function* (cwd, fallbackBranch, options) {
14351435 const details = yield* statusDetails(cwd);
14361436 const branch = details.branch ?? fallbackBranch;
14371437 if (!branch) {
......@@ -1443,6 +1443,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
14431443 );
14441444 }
14451445
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
14461463 const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0;
14471464 if (hasNoLocalDelta) {
14481465 if (details.hasUpstream) {
apps/server/src/ws.ts+28
......@@ -55,6 +55,7 @@ import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentit
5555import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
5656import { ServerAuth } from "./auth/Services/ServerAuth.ts";
5757import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts";
58import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts";
5859import * as VcsProcess from "./vcs/VcsProcess.ts";
5960import {
6061 BootstrapCredentialService,
......@@ -154,6 +155,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
154155 const serverEnvironment = yield* ServerEnvironment;
155156 const serverAuth = yield* ServerAuth;
156157 const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery;
158 const sourceControlRepositories = yield* SourceControlRepositoryService;
157159 const bootstrapCredentials = yield* BootstrapCredentialService;
158160 const sessions = yield* SessionCredentialService;
159161 const serverCommandId = (tag: string) =>
......@@ -794,6 +796,32 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
794796 "rpc.aggregate": "server",
795797 },
796798 ),
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 ),
797825 [WS_METHODS.projectsSearchEntries]: (input) =>
798826 observeRpcEffect(
799827 WS_METHODS.projectsSearchEntries,
apps/web/src/components/ChatView.browser.tsx+127-6
......@@ -206,6 +206,7 @@ function createMockEnvironmentApi(input: {
206206 filesystem: {
207207 browse: input.browse,
208208 },
209 sourceControl: {} as EnvironmentApi["sourceControl"],
209210 vcs: {} as EnvironmentApi["vcs"],
210211 git: {} as EnvironmentApi["git"],
211212 orchestration: {
......@@ -4257,8 +4258,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
42574258 });
42584259
42594260 try {
4260 await waitForServerConfigToApply();
4261 await waitForCommandPaletteShortcutLabel();
4261 await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]);
42624262 const palette = page.getByTestId("command-palette");
42634263 await openCommandPaletteFromTrigger();
42644264
......@@ -4310,8 +4310,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
43104310 });
43114311
43124312 try {
4313 await waitForServerConfigToApply();
4314 await waitForCommandPaletteShortcutLabel();
4313 await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]);
43154314 const palette = page.getByTestId("command-palette");
43164315 await openCommandPaletteFromTrigger();
43174316
......@@ -4384,13 +4383,13 @@ describe("ChatView timeline estimator parity (full app)", () => {
43844383 });
43854384
43864385 try {
4387 await waitForServerConfigToApply();
4388 await waitForCommandPaletteShortcutLabel();
4386 await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]);
43894387 const palette = page.getByTestId("command-palette");
43904388 await openCommandPaletteFromTrigger();
43914389
43924390 await expect.element(palette).toBeInTheDocument();
43934391 await palette.getByText("Add project", { exact: true }).click();
4392 await palette.getByText("Local folder", { exact: true }).click();
43944393
43954394 const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER);
43964395 await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/");
......@@ -4437,6 +4436,126 @@ describe("ChatView timeline estimator parity (full app)", () => {
44374436 }
44384437 });
44394438
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
44404559 it("opens add project browse mode from the sidebar add button", async () => {
44414560 const mounted = await mountChatView({
44424561 viewport: DEFAULT_VIEWPORT,
......@@ -4789,6 +4908,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
47894908
47904909 await expect.element(palette).toBeInTheDocument();
47914910 await palette.getByText("Add project", { exact: true }).click();
4911 await palette.getByText("Local folder", { exact: true }).click();
47924912 await expect.element(palette.getByText("Environments", { exact: true })).toBeInTheDocument();
47934913 await expect
47944914 .element(palette.getByText("This device", { exact: true }).first())
......@@ -5004,6 +5124,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
50045124
50055125 await expect.element(palette).toBeInTheDocument();
50065126 await palette.getByText("Add project", { exact: true }).click();
5127 await palette.getByText("Local folder", { exact: true }).click();
50075128
50085129 const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER);
50095130 await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/");
apps/web/src/components/CommandPalette.tsx+538-44
......@@ -7,6 +7,8 @@ import {
77 type FilesystemBrowseResult,
88 type ProjectId,
99 ProviderInstanceId,
10 type SourceControlProviderKind,
11 type SourceControlRepositoryInfo,
1012} from "@t3tools/contracts";
1113import { useQuery, useQueryClient } from "@tanstack/react-query";
1214import { useNavigate, useParams } from "@tanstack/react-router";
......@@ -17,6 +19,7 @@ import {
1719 CornerLeftUpIcon,
1820 FolderIcon,
1921 FolderPlusIcon,
22 LinkIcon,
2023 MessageSquareIcon,
2124 SettingsIcon,
2225 SquarePenIcon,
......@@ -89,6 +92,7 @@ import {
8992} from "./CommandPalette.logic";
9093import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
9194import { CommandPaletteResults } from "./CommandPaletteResults";
95import { GitHubIcon, GitLabIcon } from "./Icons";
9296import { ProjectFavicon } from "./ProjectFavicon";
9397import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
9498import { useServerKeybindings } from "../rpc/serverState";
......@@ -139,6 +143,70 @@ interface AddProjectEnvironmentOption {
139143 readonly isPrimary: boolean;
140144}
141145
146type AddProjectRemoteProviderKind = Extract<SourceControlProviderKind, "github" | "gitlab">;
147type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
148
149type 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
164const REMOTE_PROJECT_SOURCES: ReadonlyArray<AddProjectRemoteSource> = ["github", "gitlab", "url"];
165
166function 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
177function remoteProjectSourceProvider(
178 source: AddProjectRemoteSource,
179): AddProjectRemoteProviderKind | null {
180 return source === "url" ? null : source;
181}
182
183function 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
194function 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
203function 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
142210export function CommandPalette({ children }: { children: ReactNode }) {
143211 const open = useCommandPaletteStore((store) => store.open);
144212 const setOpen = useCommandPaletteStore((store) => store.setOpen);
......@@ -227,6 +295,9 @@ function OpenCommandPaletteDialog() {
227295 null,
228296 );
229297 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);
230301 const primaryEnvironmentId = usePrimaryEnvironmentId();
231302 const primaryEnvironmentLabel = readPrimaryEnvironmentDescriptor()?.label ?? null;
232303 const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((state) => state.byId);
......@@ -295,7 +366,10 @@ function OpenCommandPaletteDialog() {
295366 : null;
296367 return getEnvironmentBrowsePlatform(os);
297368 }, [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);
299373 const paletteMode = getCommandPaletteMode({ currentView, isBrowsing });
300374 const getAddProjectInitialQueryForEnvironment = useCallback(
301375 (environmentId: EnvironmentId | null): string => {
......@@ -541,6 +615,7 @@ function OpenCommandPaletteDialog() {
541615 }
542616
543617 function popView(): void {
618 setAddProjectCloneFlow(null);
544619 if (viewStack.length <= 1) {
545620 setAddProjectEnvironmentId(null);
546621 }
......@@ -560,6 +635,7 @@ function OpenCommandPaletteDialog() {
560635 const startAddProjectBrowse = useCallback(
561636 (environmentId: EnvironmentId): void => {
562637 setAddProjectEnvironmentId(environmentId);
638 setAddProjectCloneFlow(null);
563639 pushPaletteView({
564640 addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />,
565641 groups: [],
......@@ -569,6 +645,19 @@ function OpenCommandPaletteDialog() {
569645 [getAddProjectInitialQueryForEnvironment],
570646 );
571647
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
572661 const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map(
573662 (option) => ({
574663 kind: "action",
......@@ -595,6 +684,156 @@ function OpenCommandPaletteDialog() {
595684 [addProjectEnvironmentItems],
596685 );
597686
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
598837 const openAddProjectFlow = useCallback(() => {
599838 if (addProjectEnvironmentOptions.length > 1) {
600839 pushPaletteView({
......@@ -674,29 +913,29 @@ function OpenCommandPaletteDialog() {
674913 });
675914 }
676915
677 if (addProjectEnvironmentOptions.length > 1) {
678 actionItems.push({
679 kind: "submenu",
680 value: "action:add-project",
681 searchTerms: ["add project", "folder", "directory", "browse", "environment"],
682 title: "Add project",
683 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
684 addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />,
685 groups: addProjectEnvironmentGroups,
686 });
687 } else {
688 actionItems.push({
689 kind: "action",
690 value: "action:add-project",
691 searchTerms: ["add project", "folder", "directory", "browse"],
692 title: "Add project",
693 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
694 keepOpen: true,
695 run: async () => {
696 openAddProjectFlow();
697 },
698 });
699 }
916 actionItems.push({
917 kind: "submenu",
918 value: "action:add-project",
919 searchTerms: [
920 "add project",
921 "folder",
922 "directory",
923 "browse",
924 "clone",
925 "remote",
926 "repository",
927 "repo",
928 "git",
929 "github",
930 "gitlab",
931 "url",
932 "environment",
933 ],
934 title: "Add project",
935 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
936 addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />,
937 groups: addProjectSourceGroups,
938 });
700939
701940 actionItems.push({
702941 kind: "action",
......@@ -820,6 +1059,137 @@ function OpenCommandPaletteDialog() {
8201059 ],
8211060 );
8221061
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
8231193 function browseTo(name: string): void {
8241194 const nextQuery = appendBrowsePathSegment(query, name);
8251195 setHighlightedItemValue(null);
......@@ -858,13 +1228,38 @@ function OpenCommandPaletteDialog() {
8581228 browseUp,
8591229 browseTo,
8601230 });
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 }
8611243
862 let displayedGroups = filteredGroups;
863 if (isBrowsing) {
1244 return {
1245 title: addProjectCloneFlow.repository?.nameWithOwner ?? addProjectCloneFlow.repositoryInput,
1246 description: addProjectCloneFlow.repository?.url ?? addProjectCloneFlow.remoteUrl,
1247 icon: remoteProjectSourceIcon(addProjectCloneFlow.source, ITEM_ICON_CLASS),
1248 };
1249 }, [addProjectCloneFlow]);
1250
1251 let displayedGroups: CommandPaletteView["groups"] = filteredGroups;
1252 if (addProjectCloneFlow?.step === "repository") {
1253 displayedGroups = [];
1254 } else if (addProjectCloneFlow?.step === "confirm") {
1255 displayedGroups = relativePathNeedsActiveProject ? [] : cloneDestinationBrowseGroups;
1256 } else if (isBrowsing) {
8641257 displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups;
8651258 }
8661259
867 const inputPlaceholder = getCommandPaletteInputPlaceholder(paletteMode);
1260 const inputPlaceholder =
1261 remoteProjectInputPlaceholder(addProjectCloneFlow) ??
1262 getCommandPaletteInputPlaceholder(paletteMode);
8681263 const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse";
8691264 const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false;
8701265 const canSubmitBrowsePath = isBrowsing && !relativePathNeedsActiveProject;
......@@ -876,8 +1271,25 @@ function OpenCommandPaletteDialog() {
8761271 (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null);
8771272 const useMetaForMod = isMacPlatform(navigator.platform);
8781273 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";
8801282 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;
8811293 const fileManagerName = getLocalFileManagerName(navigator.platform);
8821294 const canOpenProjectFromFileManager =
8831295 isBrowsing &&
......@@ -915,6 +1327,12 @@ function OpenCommandPaletteDialog() {
9151327 }
9161328
9171329 function handleKeyDown(event: KeyboardEvent<HTMLInputElement>): void {
1330 if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") {
1331 event.preventDefault();
1332 void submitAddProjectCloneFlow();
1333 return;
1334 }
1335
9181336 const shouldSubmitBrowsePath =
9191337 canSubmitBrowsePath &&
9201338 event.key === "Enter" &&
......@@ -922,7 +1340,11 @@ function OpenCommandPaletteDialog() {
9221340
9231341 if (shouldSubmitBrowsePath) {
9241342 event.preventDefault();
925 void handleAddProject(resolvedAddProjectPath);
1343 if (isCloneDestinationStep) {
1344 void submitAddProjectCloneFlow(resolvedAddProjectPath);
1345 } else {
1346 void handleAddProject(resolvedAddProjectPath);
1347 }
9261348 return;
9271349 }
9281350
......@@ -999,9 +1421,9 @@ function OpenCommandPaletteDialog() {
9991421 }}
10001422 >
10011423 <Command
1002 key={`${viewStack.length}-${browseGeneration}-${isBrowsing}`}
1424 key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${addProjectCloneFlow?.step ?? "none"}`}
10031425 aria-label="Command palette"
1004 autoHighlight={isBrowsing ? false : "always"}
1426 autoHighlight={isBrowsing || isRemoteProjectCloneFlow ? false : "always"}
10051427 mode="none"
10061428 onItemHighlighted={(value) => {
10071429 setHighlightedItemValue(typeof value === "string" ? value : null);
......@@ -1011,7 +1433,15 @@ function OpenCommandPaletteDialog() {
10111433 >
10121434 <div className="relative">
10131435 <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 }
10151445 placeholder={inputPlaceholder}
10161446 wrapperClassName={
10171447 isSubmenu ? "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto" : undefined
......@@ -1036,7 +1466,28 @@ function OpenCommandPaletteDialog() {
10361466 : {})}
10371467 onKeyDown={handleKeyDown}
10381468 />
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 ? (
10401491 <Button
10411492 variant="outline"
10421493 size="xs"
......@@ -1046,7 +1497,9 @@ function OpenCommandPaletteDialog() {
10461497 hasHighlightedBrowseItem ? "gap-1" : "gap-1.5",
10471498 )}
10481499 aria-label={`${submitActionLabel} (${addShortcutLabel})`}
1049 disabled={relativePathNeedsActiveProject}
1500 disabled={
1501 relativePathNeedsActiveProject || (isCloneDestinationStep && isRemoteProjectPending)
1502 }
10501503 onMouseDown={(event) => {
10511504 event.preventDefault();
10521505 }}
......@@ -1054,11 +1507,17 @@ function OpenCommandPaletteDialog() {
10541507 if (relativePathNeedsActiveProject) {
10551508 return;
10561509 }
1057 void handleAddProject(resolvedAddProjectPath);
1510 if (isCloneDestinationStep) {
1511 void submitAddProjectCloneFlow(resolvedAddProjectPath);
1512 } else {
1513 void handleAddProject(resolvedAddProjectPath);
1514 }
10581515 }}
10591516 title={`${submitActionLabel} (${addShortcutLabel})`}
10601517 >
1061 <span>{submitActionLabel}</span>
1518 <span>
1519 {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel}
1520 </span>
10621521 <KbdGroup className="pointer-events-none -me-0.5 items-center gap-1">
10631522 <Kbd>{hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"}</Kbd>
10641523 </KbdGroup>
......@@ -1066,19 +1525,47 @@ function OpenCommandPaletteDialog() {
10661525 ) : null}
10671526 </div>
10681527 <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}
10691546 <CommandPaletteResults
10701547 groups={displayedGroups}
10711548 highlightedItemValue={highlightedItemValue}
10721549 isActionsOnly={isActionsOnly}
10731550 keybindings={keybindings}
10741551 onExecuteItem={executeItem}
1075 {...(relativePathNeedsActiveProject
1076 ? { emptyStateMessage: "Relative paths require an active project." }
1077 : willCreateProjectPath
1078 ? {
1079 emptyStateMessage: "Press Enter to create this folder and add it as a project.",
1080 }
1081 : {})}
1552 {...(addProjectCloneFlow?.step === "repository"
1553 ? {
1554 emptyStateMessage:
1555 addProjectCloneFlow.source === "url"
1556 ? "Enter a Git clone URL and press Enter to continue."
1557 : "Enter a repository path and press Enter to look it up.",
1558 }
1559 : addProjectCloneFlow?.step === "confirm"
1560 ? { emptyStateMessage: "Choose a destination path and press Enter to clone." }
1561 : relativePathNeedsActiveProject
1562 ? { emptyStateMessage: "Relative paths require an active project." }
1563 : willCreateProjectPath
1564 ? {
1565 emptyStateMessage:
1566 "Press Enter to create this folder and add it as a project.",
1567 }
1568 : {})}
10821569 />
10831570 </CommandPanel>
10841571 <CommandFooter className="gap-3 max-sm:flex-col max-sm:items-start">
......@@ -1092,7 +1579,14 @@ function OpenCommandPaletteDialog() {
10921579 </Kbd>
10931580 <span className={cn("text-muted-foreground/80")}>Navigate</span>
10941581 </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 ? (
10961590 <KbdGroup className="items-center gap-1.5">
10971591 <Kbd>Enter</Kbd>
10981592 <span className={cn("text-muted-foreground/80")}>Select</span>
apps/web/src/components/GitActionsControl.browser.tsx+2
......@@ -100,12 +100,14 @@ vi.mock("~/editorPreferences", () => ({
100100vi.mock("~/lib/gitReactQuery", () => ({
101101 gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })),
102102 gitMutationKeys: {
103 publishRepository: vi.fn(() => ["publish-repository"]),
103104 pull: vi.fn(() => ["pull"]),
104105 runStackedAction: vi.fn(() => ["run-stacked-action"]),
105106 },
106107 gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })),
107108 gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })),
108109 invalidateGitQueries: invalidateGitQueriesSpy,
110 sourceControlPublishRepositoryMutationOptions: vi.fn(() => ({ __kind: "publish-repository" })),
109111}));
110112
111113vi.mock("~/lib/gitStatusState", () => ({
apps/web/src/components/GitActionsControl.logic.test.ts+5-22
......@@ -730,7 +730,7 @@ describe("when: ref has no upstream configured", () => {
730730 });
731731 });
732732
733 it("resolveQuickAction disables push-and-pr flows when no origin remote exists", () => {
733 it("resolveQuickAction publishes when no origin remote exists", () => {
734734 const quick = resolveQuickAction(
735735 status({
736736 hasUpstream: false,
......@@ -742,10 +742,9 @@ describe("when: ref has no upstream configured", () => {
742742 false,
743743 );
744744 assert.deepEqual(quick, {
745 kind: "show_hint",
746 label: "Push",
747 hint: 'Add an "origin" remote before pushing or creating a pull request.',
748 disabled: true,
745 kind: "open_publish",
746 label: "Publish repository",
747 disabled: false,
749748 });
750749 });
751750
......@@ -779,7 +778,7 @@ describe("when: ref has no upstream configured", () => {
779778 ]);
780779 });
781780
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", () => {
783782 const items = buildMenuItems(
784783 status({ hasUpstream: false, pr: null, aheadCount: 2 }),
785784 false,
......@@ -794,22 +793,6 @@ describe("when: ref has no upstream configured", () => {
794793 kind: "open_dialog",
795794 dialogAction: "commit",
796795 },
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 },
813796 ]);
814797 });
815798
apps/web/src/components/GitActionsControl.logic.ts+18-13
......@@ -26,7 +26,7 @@ export interface GitActionMenuItem {
2626export interface GitQuickAction {
2727 label: string;
2828 disabled: boolean;
29 kind: "run_action" | "run_pull" | "open_pr" | "show_hint";
29 kind: "run_action" | "run_pull" | "open_pr" | "open_publish" | "show_hint";
3030 action?: GitStackedAction;
3131 hint?: string;
3232}
......@@ -122,15 +122,21 @@ export function buildMenuItems(
122122 (gitStatus.hasUpstream || canPushWithoutUpstream);
123123 const canOpenPr = !isBusy && hasOpenPr;
124124
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
125138 return [
126 {
127 id: "commit",
128 label: "Commit",
129 disabled: !canCommit,
130 icon: "commit",
131 kind: "open_dialog",
132 dialogAction: "commit",
133 },
139 commitItem,
134140 {
135141 id: "push",
136142 label: "Push",
......@@ -216,10 +222,9 @@ export function resolveQuickAction(
216222 return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" };
217223 }
218224 return {
219 label: "Push",
220 disabled: true,
221 kind: "show_hint",
222 hint: `Add an "origin" remote before pushing or creating a ${terminology.singular}.`,
225 label: "Publish repository",
226 disabled: false,
227 kind: "open_publish",
223228 };
224229 }
225230 if (!isAhead) {
apps/web/src/components/GitActionsControl.tsx+550-2
......@@ -3,11 +3,30 @@ import type {
33 GitActionProgressEvent,
44 GitRunStackedActionResult,
55 GitStackedAction,
6 SourceControlCloneProtocol,
7 SourceControlProviderKind,
8 SourceControlPublishRepositoryResult,
9 SourceControlRepositoryVisibility,
610 VcsStatusResult,
711} from "@t3tools/contracts";
812import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
13import { Option } from "effect";
914import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
10import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
15import {
16 CheckIcon,
17 ChevronDownIcon,
18 CloudUploadIcon,
19 ExternalLinkIcon,
20 GitCommitIcon,
21 InfoIcon,
22 LockIcon,
23 GlobeIcon,
24} from "lucide-react";
25import { Radio as RadioPrimitive } from "@base-ui/react/radio";
26import { GitHubIcon, GitLabIcon } from "~/components/Icons";
27import { RadioGroup } from "~/components/ui/radio-group";
28import { Spinner } from "~/components/ui/spinner";
29import { cn } from "~/lib/utils";
1130import {
1231 buildGitActionProgressStages,
1332 buildMenuItems,
......@@ -33,9 +52,18 @@ import {
3352 DialogTitle,
3453} from "~/components/ui/dialog";
3554import { Group, GroupSeparator } from "~/components/ui/group";
55import { Input } from "~/components/ui/input";
3656import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
3757import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
3858import { ScrollArea } from "~/components/ui/scroll-area";
59import {
60 Select,
61 SelectGroup,
62 SelectItem,
63 SelectPopup,
64 SelectTrigger,
65 SelectValue,
66} from "~/components/ui/select";
3967import { Textarea } from "~/components/ui/textarea";
4068import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast";
4169import { openInPreferredEditor } from "~/editorPreferences";
......@@ -44,8 +72,10 @@ import {
4472 gitMutationKeys,
4573 gitPullMutationOptions,
4674 gitRunStackedActionMutationOptions,
75 sourceControlPublishRepositoryMutationOptions,
4776} from "~/lib/gitReactQuery";
4877import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";
78import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState";
4979import { newCommandId, randomUUID } from "~/lib/utils";
5080import { resolvePathLinkTarget } from "~/terminal-links";
5181import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
......@@ -70,6 +100,8 @@ interface PendingDefaultBranchAction {
70100 filePaths?: string[];
71101}
72102
103type PublishProviderKind = Extract<SourceControlProviderKind, "github" | "gitlab">;
104
73105type GitActionToastId = ReturnType<typeof toastManager.add>;
74106
75107interface ActiveGitActionProgress {
......@@ -211,6 +243,7 @@ function GitQuickActionIcon({
211243}) {
212244 const iconClassName = "size-3.5";
213245 if (quickAction.kind === "open_pr") return <SourceControlIcon className={iconClassName} />;
246 if (quickAction.kind === "open_publish") return <CloudUploadIcon className={iconClassName} />;
214247 if (quickAction.kind === "run_pull") return <InfoIcon className={iconClassName} />;
215248 if (quickAction.kind === "run_action") {
216249 if (quickAction.action === "commit") return <GitCommitIcon className={iconClassName} />;
......@@ -252,8 +285,35 @@ export default function GitActionsControl({
252285 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
253286 const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());
254287 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);
255302 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =
256303 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 : "";
257317 const activeGitActionProgressRef = useRef<ActiveGitActionProgress | null>(null);
258318 let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise<void>;
259319
......@@ -365,6 +425,13 @@ export default function GitActionsControl({
365425 const pullMutation = useMutation(
366426 gitPullMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }),
367427 );
428 const publishRepositoryMutation = useMutation(
429 sourceControlPublishRepositoryMutationOptions({
430 environmentId: activeEnvironmentId,
431 cwd: gitCwd,
432 queryClient,
433 }),
434 );
368435
369436 const isRunStackedActionRunning =
370437 useIsMutating({
......@@ -372,7 +439,11 @@ export default function GitActionsControl({
372439 }) > 0;
373440 const isPullRunning =
374441 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;
376447 const isSelectingWorktreeBase =
377448 !activeServerThread &&
378449 activeDraftThread?.envMode === "worktree" &&
......@@ -439,6 +510,13 @@ export default function GitActionsControl({
439510 };
440511 }, [updateActiveProgressToast]);
441512
513 useEffect(() => {
514 if (!isPublishDialogOpen || hasUserEditedPublishRepository) {
515 return;
516 }
517 setPublishRepository(publishRepositoryPrefill);
518 }, [isPublishDialogOpen, hasUserEditedPublishRepository, publishRepositoryPrefill]);
519
442520 useEffect(() => {
443521 if (gitCwd === null) {
444522 return;
......@@ -785,6 +863,10 @@ export default function GitActionsControl({
785863 void openExistingPr();
786864 return;
787865 }
866 if (quickAction.kind === "open_publish") {
867 setIsPublishDialogOpen(true);
868 return;
869 }
788870 if (quickAction.kind === "run_pull") {
789871 const promise = pullMutation.mutateAsync();
790872 void toastManager.promise<
......@@ -882,6 +964,47 @@ export default function GitActionsControl({
882964 [gitCwd, threadToastData],
883965 );
884966
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
8851008 if (!gitCwd) return null;
8861009
8871010 return (
......@@ -996,6 +1119,17 @@ export default function GitActionsControl({
9961119 </MenuItem>
9971120 );
9981121 })}
1122 {canPublishRepository ? (
1123 <MenuItem
1124 disabled={isGitActionRunning}
1125 onClick={() => {
1126 setIsPublishDialogOpen(true);
1127 }}
1128 >
1129 <CloudUploadIcon />
1130 Publish repository...
1131 </MenuItem>
1132 ) : null}
9991133 {gitStatusForActions?.refName === null && (
10001134 <p className="px-2 py-1.5 text-xs text-warning">
10011135 Detached HEAD: create and checkout a refName to enable push and pull request
......@@ -1188,6 +1322,420 @@ export default function GitActionsControl({
11881322 </DialogPopup>
11891323 </Dialog>
11901324
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
11911739 <Dialog
11921740 open={pendingDefaultBranchAction !== null}
11931741 onOpenChange={(open) => {
apps/web/src/components/settings/SourceControlSettings.tsx+9-2
......@@ -57,6 +57,8 @@ const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = {
5757 jj: JujutsuIcon,
5858};
5959
60const SOURCE_CONTROL_SKELETON_ROWS = ["primary", "secondary"] as const;
61
6062function optionLabel(value: Option.Option<string>): string | null {
6163 return Option.getOrNull(value);
6264}
......@@ -157,6 +159,11 @@ function itemSummary({
157159 </>
158160 );
159161 }
162
163 if (!item.executable) {
164 return <span>{item.installHint}</span>;
165 }
166
160167 if (auth.status === "unauthenticated") {
161168 return <span>Sign in with the {item.executable} CLI to enable pull request actions.</span>;
162169 }
......@@ -232,8 +239,8 @@ function SourceControlSectionSkeleton({
232239}) {
233240 return (
234241 <SettingsSection title={title} headerAction={headerAction}>
235 {Array.from({ length: 2 }, (_, index) => (
236 <div key={index} className="border-t border-border/60 px-4 py-3.5 first:border-t-0 sm:px-5">
242 {SOURCE_CONTROL_SKELETON_ROWS.map((row) => (
243 <div key={row} className="border-t border-border/60 px-4 py-3.5 first:border-t-0 sm:px-5">
237244 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
238245 <div className="min-w-0 flex-1 space-y-2">
239246 <div className="flex items-center gap-2">
apps/web/src/environmentApi.ts+5
......@@ -23,6 +23,11 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {
2323 filesystem: {
2424 browse: rpcClient.filesystem.browse,
2525 },
26 sourceControl: {
27 lookupRepository: rpcClient.sourceControl.lookupRepository,
28 cloneRepository: rpcClient.sourceControl.cloneRepository,
29 publishRepository: rpcClient.sourceControl.publishRepository,
30 },
2631 vcs: {
2732 pull: rpcClient.vcs.pull,
2833 refreshStatus: rpcClient.vcs.refreshStatus,
apps/web/src/lib/gitReactQuery.ts+23
......@@ -2,6 +2,7 @@ import {
22 type EnvironmentId,
33 type GitActionProgressEvent,
44 type GitStackedAction,
5 type SourceControlPublishRepositoryInput,
56 type ThreadId,
67} from "@t3tools/contracts";
78import {
......@@ -36,6 +37,8 @@ export const gitMutationKeys = {
3637 ["git", "mutation", "pull", environmentId ?? null, cwd] as const,
3738 preparePullRequestThread: (environmentId: EnvironmentId | null, cwd: string | null) =>
3839 ["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,
3942};
4043
4144export function invalidateGitQueries(
......@@ -228,6 +231,26 @@ export function gitPullMutationOptions(input: {
228231 });
229232}
230233
234export 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
231254/**
232255 * @deprecated Use a VCS-named mutation helper once the UI naming migration lands.
233256 */
apps/web/src/localApi.test.ts+5
......@@ -56,6 +56,11 @@ const rpcClientMock = {
5656 filesystem: {
5757 browse: vi.fn(),
5858 },
59 sourceControl: {
60 lookupRepository: vi.fn(),
61 cloneRepository: vi.fn(),
62 publishRepository: vi.fn(),
63 },
5964 shell: {
6065 openInEditor: vi.fn(),
6166 },
apps/web/src/rpc/wsRpcClient.ts+13
......@@ -71,6 +71,11 @@ export interface WsRpcClient {
7171 readonly filesystem: {
7272 readonly browse: RpcUnaryMethod<typeof WS_METHODS.filesystemBrowse>;
7373 };
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 };
7479 readonly shell: {
7580 readonly openInEditor: (input: {
7681 readonly cwd: Parameters<LocalApi["shell"]["openInEditor"]>[0];
......@@ -165,6 +170,14 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
165170 filesystem: {
166171 browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)),
167172 },
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 },
168181 shell: {
169182 openInEditor: (input) =>
170183 transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)),
packages/contracts/src/ipc.ts+20-1
......@@ -55,7 +55,15 @@ import type {
5555import type { EnvironmentId } from "./baseSchemas.ts";
5656import { EditorId } from "./editor.ts";
5757import { ServerSettings, type ClientSettings, type ServerSettingsPatch } from "./settings.ts";
58import type { SourceControlDiscoveryResult } from "./sourceControl.ts";
58import type {
59 SourceControlCloneRepositoryInput,
60 SourceControlCloneRepositoryResult,
61 SourceControlDiscoveryResult,
62 SourceControlPublishRepositoryInput,
63 SourceControlPublishRepositoryResult,
64 SourceControlRepositoryInfo,
65 SourceControlRepositoryLookupInput,
66} from "./sourceControl.ts";
5967
6068export interface ContextMenuItem<T extends string = string> {
6169 id: T;
......@@ -258,6 +266,17 @@ export interface EnvironmentApi {
258266 filesystem: {
259267 browse: (input: FilesystemBrowseInput) => Promise<FilesystemBrowseResult>;
260268 };
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 };
261280 vcs: {
262281 listRefs: (input: VcsListRefsInput) => Promise<VcsListRefsResult>;
263282 createWorktree: (input: VcsCreateWorktreeInput) => Promise<VcsCreateWorktreeResult>;
packages/contracts/src/rpc.ts+42-1
......@@ -77,7 +77,16 @@ import {
7777 ServerUpsertKeybindingResult,
7878} from "./server.ts";
7979import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts";
80import { SourceControlDiscoveryResult } from "./sourceControl.ts";
80import {
81 SourceControlCloneRepositoryInput,
82 SourceControlCloneRepositoryResult,
83 SourceControlDiscoveryResult,
84 SourceControlPublishRepositoryInput,
85 SourceControlPublishRepositoryResult,
86 SourceControlRepositoryError,
87 SourceControlRepositoryInfo,
88 SourceControlRepositoryLookupInput,
89} from "./sourceControl.ts";
8190import { VcsError } from "./vcs.ts";
8291
8392export const WS_METHODS = {
......@@ -125,6 +134,11 @@ export const WS_METHODS = {
125134 serverUpdateSettings: "server.updateSettings",
126135 serverDiscoverSourceControl: "server.discoverSourceControl",
127136
137 // Source control methods
138 sourceControlLookupRepository: "sourceControl.lookupRepository",
139 sourceControlCloneRepository: "sourceControl.cloneRepository",
140 sourceControlPublishRepository: "sourceControl.publishRepository",
141
128142 // Streaming subscriptions
129143 subscribeVcsStatus: "subscribeVcsStatus",
130144 subscribeTerminalEvents: "subscribeTerminalEvents",
......@@ -175,6 +189,30 @@ export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscov
175189 success: SourceControlDiscoveryResult,
176190});
177191
192export const WsSourceControlLookupRepositoryRpc = Rpc.make(
193 WS_METHODS.sourceControlLookupRepository,
194 {
195 payload: SourceControlRepositoryLookupInput,
196 success: SourceControlRepositoryInfo,
197 error: SourceControlRepositoryError,
198 },
199);
200
201export const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlCloneRepository, {
202 payload: SourceControlCloneRepositoryInput,
203 success: SourceControlCloneRepositoryResult,
204 error: SourceControlRepositoryError,
205});
206
207export const WsSourceControlPublishRepositoryRpc = Rpc.make(
208 WS_METHODS.sourceControlPublishRepository,
209 {
210 payload: SourceControlPublishRepositoryInput,
211 success: SourceControlPublishRepositoryResult,
212 error: SourceControlRepositoryError,
213 },
214);
215
178216export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, {
179217 payload: ProjectSearchEntriesInput,
180218 success: ProjectSearchEntriesResult,
......@@ -381,6 +419,9 @@ export const WsRpcGroup = RpcGroup.make(
381419 WsServerGetSettingsRpc,
382420 WsServerUpdateSettingsRpc,
383421 WsServerDiscoverSourceControlRpc,
422 WsSourceControlLookupRepositoryRpc,
423 WsSourceControlCloneRepositoryRpc,
424 WsSourceControlPublishRepositoryRpc,
384425 WsProjectsSearchEntriesRpc,
385426 WsProjectsWriteFileRpc,
386427 WsShellOpenInEditorRpc,
packages/contracts/src/sourceControl.ts+75-1
......@@ -43,6 +43,66 @@ export const SourceControlRepositoryCloneUrls = Schema.Struct({
4343});
4444export type SourceControlRepositoryCloneUrls = typeof SourceControlRepositoryCloneUrls.Type;
4545
46export const SourceControlRepositoryVisibility = Schema.Literals(["private", "public"]);
47export type SourceControlRepositoryVisibility = typeof SourceControlRepositoryVisibility.Type;
48
49export const SourceControlCloneProtocol = Schema.Literals(["auto", "ssh", "https"]);
50export type SourceControlCloneProtocol = typeof SourceControlCloneProtocol.Type;
51
52export const SourceControlRepositoryInfo = Schema.Struct({
53 provider: SourceControlProviderKind,
54 nameWithOwner: TrimmedNonEmptyString,
55 url: TrimmedNonEmptyString,
56 sshUrl: TrimmedNonEmptyString,
57});
58export type SourceControlRepositoryInfo = typeof SourceControlRepositoryInfo.Type;
59
60export const SourceControlRepositoryLookupInput = Schema.Struct({
61 provider: SourceControlProviderKind,
62 repository: TrimmedNonEmptyString,
63 cwd: Schema.optional(TrimmedNonEmptyString),
64});
65export type SourceControlRepositoryLookupInput = typeof SourceControlRepositoryLookupInput.Type;
66
67export 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});
74export type SourceControlCloneRepositoryInput = typeof SourceControlCloneRepositoryInput.Type;
75
76export const SourceControlCloneRepositoryResult = Schema.Struct({
77 cwd: TrimmedNonEmptyString,
78 remoteUrl: TrimmedNonEmptyString,
79 repository: Schema.NullOr(SourceControlRepositoryInfo),
80});
81export type SourceControlCloneRepositoryResult = typeof SourceControlCloneRepositoryResult.Type;
82
83export 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});
91export type SourceControlPublishRepositoryInput = typeof SourceControlPublishRepositoryInput.Type;
92
93export const SourceControlPublishStatus = Schema.Literals(["pushed", "remote_added"]);
94export type SourceControlPublishStatus = typeof SourceControlPublishStatus.Type;
95
96export 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});
104export type SourceControlPublishRepositoryResult = typeof SourceControlPublishRepositoryResult.Type;
105
46106export const SourceControlDiscoveryStatus = Schema.Literals(["available", "missing"]);
47107export type SourceControlDiscoveryStatus = typeof SourceControlDiscoveryStatus.Type;
48108
......@@ -63,7 +123,7 @@ export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type;
63123
64124const SourceControlDiscoveryItemFields = {
65125 label: TrimmedNonEmptyString,
66 executable: TrimmedNonEmptyString,
126 executable: Schema.optional(TrimmedNonEmptyString),
67127 implemented: Schema.Boolean,
68128 status: SourceControlDiscoveryStatus,
69129 version: Schema.Option(TrimmedNonEmptyString),
......@@ -109,3 +169,17 @@ export class SourceControlProviderError extends Schema.TaggedErrorClass<SourceCo
109169 return `Source control provider ${this.provider} failed in ${this.operation}: ${this.detail}`;
110170 }
111171}
172
173export 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}