History

Add remote repository publish and discovery support (#2482)

Julius Marminge committed 6 months ago

Commit d7969261 parent f7748a033 files · +2502 −157Browse files

Unified · Split

33 files
  1. apps/server/src/git/GitManager.test.ts +7 −0
  2. apps/server/src/server.test.ts +10 −0
  3. apps/server/src/server.ts +13 −6
  4. apps/server/src/sourceControl/GitHubCli.test.ts +52 −0
  5. apps/server/src/sourceControl/GitHubCli.ts +57 −1
  6. apps/server/src/sourceControl/GitHubSourceControlProvider.ts +4 −0
  7. apps/server/src/sourceControl/GitLabCli.test.ts +62 −1
  8. apps/server/src/sourceControl/GitLabCli.ts +79 −3
  9. apps/server/src/sourceControl/GitLabSourceControlProvider.ts +4 −0
  10. apps/server/src/sourceControl/SourceControlDiscovery.test.ts +6 −3
  11. apps/server/src/sourceControl/SourceControlDiscovery.ts +44 −50
  12. apps/server/src/sourceControl/SourceControlProvider.ts +6 −0
  13. apps/server/src/sourceControl/SourceControlProviderRegistry.ts +1 −0
  14. apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +322 −0
  15. apps/server/src/sourceControl/SourceControlRepositoryService.ts +320 −0
  16. apps/server/src/vcs/GitVcsDriver.ts +1 −0
  17. apps/server/src/vcs/GitVcsDriverCore.test.ts +36 −0
  18. apps/server/src/vcs/GitVcsDriverCore.ts +18 −1
  19. apps/server/src/ws.ts +28 −0
  20. apps/web/src/components/ChatView.browser.tsx +127 −6
  21. apps/web/src/components/CommandPalette.tsx +538 −44
  22. apps/web/src/components/GitActionsControl.browser.tsx +2 −0
  23. apps/web/src/components/GitActionsControl.logic.test.ts +5 −22
  24. apps/web/src/components/GitActionsControl.logic.ts +18 −13
  25. apps/web/src/components/GitActionsControl.tsx +550 −2
  26. apps/web/src/components/settings/SourceControlSettings.tsx +9 −2
  27. apps/web/src/environmentApi.ts +5 −0
  28. apps/web/src/lib/gitReactQuery.ts +23 −0
  29. apps/web/src/localApi.test.ts +5 −0
  30. apps/web/src/rpc/wsRpcClient.ts +13 −0
  31. packages/contracts/src/ipc.ts +20 −1
  32. packages/contracts/src/rpc.ts +42 −1
  33. packages/contracts/src/sourceControl.ts +75 −1
apps/server/src/git/GitManager.test.ts+7−0
@@ -580,6 +580,13 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { · 579 unmodified lines
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−0
@@ -117,6 +117,10 @@ import { · 116 unmodified lines
117117 } from "./vcs/VcsDriverRegistry.ts";
118118 import { layer as VcsProvisioningServiceLayer } from "./vcs/VcsProvisioningService.ts";
119119 import { layer as GitWorkflowServiceLayer } from "./git/GitWorkflowService.ts";
120+import {
121+ SourceControlRepositoryService,
122+ type SourceControlRepositoryServiceShape,
123+} from "./sourceControl/SourceControlRepositoryService.ts";
120124 import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
121125 import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";
122126
@@ -330,6 +334,7 @@ const buildAppUnderTest = (options?: { · 207 unmodified lines
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?: { · 209 unmodified lines
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"; · 53 unmodified lines
5454 import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
5555 import * as GitWorkflowService from "./git/GitWorkflowService.ts";
5656 import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
57+import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
5758 import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts";
5859 import { ObservabilityLive } from "./observability/Layers/Observability.ts";
5960 import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts";
@@ -161,15 +162,15 @@ const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( · 101 unmodified lines
161162 Layer.provide(VcsProjectConfig.layer),
162163 );
163164
165+const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe(
166+ Layer.provide(Layer.mergeAll(GitHubCli.layer, GitLabCli.layer)),
167+ Layer.provideMerge(VcsDriverRegistryLayerLive),
168+);
169+
164170 const 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( · 7 unmodified lines
183184 Layer.provideMerge(GitLayerLive),
184185 );
185186
187+const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.layer.pipe(
188+ Layer.provideMerge(GitVcsDriver.layer),
189+ Layer.provideMerge(SourceControlProviderRegistryLayerLive),
190+);
191+
186192 const 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−0
@@ -211,6 +211,58 @@ describe("GitHubCli.layer", () => { · 210 unmodified lines
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 @@
11 import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect";
22
3−import { TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts";
3+import {
4+ TrimmedNonEmptyString,
5+ type SourceControlRepositoryVisibility,
6+ type VcsError,
7+} from "@t3tools/contracts";
48
59 import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
610 import {
@@ -62,6 +66,12 @@ export interface GitHubCliShape { · 55 unmodified lines
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( · 92 unmodified lines
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+ */
179+function deriveRepositoryCloneUrlsFromCreateOutput(
180+ stdout: string,
181+ repository: string,
182+): GitHubRepositoryCloneUrls {
183+ const fallbackHost = "github.com";
184+ const match = stdout.match(/https?:\/\/[^\s]+/);
185+ if (match) {
186+ const cleaned = match[0].replace(/\.git$/, "");
187+ try {
188+ const parsed = new URL(cleaned);
189+ const pathname = parsed.pathname.replace(/^\/+|\/+$/g, "");
190+ const segments = pathname.split("/").filter(Boolean);
191+ if (segments.length === 2) {
192+ const nameWithOwner = `${segments[0]}/${segments[1]}`;
193+ return {
194+ nameWithOwner,
195+ url: `${parsed.origin}/${nameWithOwner}`,
196+ sshUrl: `git@${parsed.host}:${nameWithOwner}.git`,
197+ };
198+ }
199+ } catch {
200+ // Fall through to the input-derived defaults below.
201+ }
202+ }
203+ return {
204+ nameWithOwner: repository,
205+ url: `https://${fallbackHost}/${repository}`,
206+ sshUrl: `git@${fallbackHost}:${repository}.git`,
207+ };
208+}
209+
163210 function decodeGitHubJson<S extends Schema.Top>(
164211 raw: string,
165212 schema: S,
@@ -281,6 +328,15 @@ export const make = Effect.fn("makeGitHubCli")(function* () { · 115 unmodified lines
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−0
@@ -130,6 +130,10 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { · 129 unmodified lines
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) => { · 174 unmodified lines
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) => { · 35 unmodified lines
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 @@
11 import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, type DateTime } from "effect";
22
3−import { TrimmedNonEmptyString } from "@t3tools/contracts";
3+import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts";
44
55 import {
66 decodeGitLabMergeRequestJson,
@@ -66,6 +66,12 @@ export interface GitLabCliShape { · 59 unmodified lines
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({ · 89 unmodified lines
161167 default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
162168 });
163169
170+const RawGitLabNamespaceSchema = Schema.Struct({
171+ id: Schema.Number,
172+});
173+
164174 function 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( · 1 unmodified line
174184 function 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( · 47 unmodified lines
228238 return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary;
229239 }
230240
241+function parseRepositoryPath(repository: string): {
242+ readonly namespacePath: string | null;
243+ readonly projectPath: string;
244+} {
245+ const parts = repository
246+ .split("/")
247+ .map((part) => part.trim())
248+ .filter((part) => part.length > 0);
249+ const projectPath = parts.at(-1) ?? repository.trim();
250+ const namespacePath = parts.length > 1 ? parts.slice(0, -1).join("/") : null;
251+ return { namespacePath, projectPath };
252+}
253+
231254 export const make = Effect.fn("makeGitLabCli")(function* () {
232255 const process = yield* VcsProcess;
233256
@@ -320,6 +343,59 @@ export const make = Effect.fn("makeGitLabCli")(function* () { · 86 unmodified lines
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−0
@@ -92,6 +92,10 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { · 91 unmodified lines
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) · 114 unmodified lines
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 · 83 unmodified lines
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"; · 12 unmodified lines
1313
1414 interface 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
2222 type VcsProbe = DiscoveryProbe & {
2323 readonly kind: VcsDriverKind;
24+ readonly executable: string;
25+ readonly versionArgs: ReadonlyArray<string>;
2426 };
2527
2628 type 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
3234 interface AuthProbeInput {
@@ -38,7 +40,7 @@ interface AuthProbeInput { · 5 unmodified lines
3840 interface 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> = [ · 56 unmodified lines
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 { · 153 unmodified lines
266264 });
267265 }
268266
269−function parseBitbucketAuth(input: AuthProbeInput): SourceControlProviderAuth {
270− const output = combinedAuthOutput(input);
271− const account = matchFirst(output, [
272− /Logged in to .* as\s+([^\s(]+)/iu,
273− /Logged in as\s+([^\s(]+)/iu,
274− /account:\s*([^\s(]+)/iu,
275− /user:\s*([^\s(]+)/iu,
276− /username:\s*([^\s(]+)/iu,
277− ]);
278− const host = parseCliHost(output) ?? "bitbucket.org";
279−
280− if (input.exitCode !== 0) {
281− return providerAuth({
282− status: "unauthenticated",
283− host,
284− detail:
285− firstSafeAuthLine(output) ?? "Authenticate the Bitbucket CLI before enabling Bitbucket.",
286− });
287− }
288−
289− if (account) {
290− return providerAuth({ status: "authenticated", account, host });
291− }
292−
293− return providerAuth({
294− status: "unknown",
295− host,
296− detail: firstSafeAuthLine(output) ?? "Bitbucket CLI auth status could not be parsed.",
297− });
298−}
299−
300267 export interface SourceControlDiscoveryShape {
301268 readonly discover: Effect.Effect<SourceControlDiscoveryResult>;
302269 }
@@ -314,12 +281,27 @@ export const layer = Layer.effect( · 11 unmodified lines
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( · 5 unmodified lines
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( · 7 unmodified lines
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( · 2 unmodified lines
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( · 4 unmodified lines
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( · 5 unmodified lines
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−0
@@ -6,6 +6,7 @@ import type { · 5 unmodified lines
66 SourceControlProviderInfo,
77 SourceControlProviderKind,
88 SourceControlRepositoryCloneUrls,
9+ SourceControlRepositoryVisibility,
910 } from "@t3tools/contracts";
1011
1112 export interface SourceControlProviderContext {
@@ -50,6 +51,11 @@ export interface SourceControlProviderShape { · 38 unmodified lines
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−0
@@ -58,6 +58,7 @@ function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProv · 57 unmodified lines
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+322−0
@@ -0,0 +1,322 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { assert, it } from "@effect/vitest";
3+import { Effect, FileSystem, Layer } from "effect";
4+import { ChildProcessSpawner } from "effect/unstable/process";
5+
6+import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts";
7+
8+import { ServerConfig } from "../config.ts";
9+import {
10+ GitVcsDriver,
11+ type ExecuteGitResult,
12+ type GitVcsDriverShape,
13+} from "../vcs/GitVcsDriver.ts";
14+import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts";
15+import type { SourceControlProviderShape } from "./SourceControlProvider.ts";
16+import { SourceControlRepositoryService, layer } from "./SourceControlRepositoryService.ts";
17+
18+const CLONE_URLS = {
19+ nameWithOwner: "octocat/t3code",
20+ url: "https://github.com/octocat/t3code",
21+ sshUrl: "git@github.com:octocat/t3code.git",
22+};
23+
24+function makeProvider(
25+ overrides: Partial<SourceControlProviderShape> = {},
26+): SourceControlProviderShape {
27+ const unsupported = (operation: string) =>
28+ Effect.die(`unexpected provider operation ${operation}`) as Effect.Effect<
29+ never,
30+ SourceControlProviderError
31+ >;
32+
33+ return {
34+ kind: "github",
35+ listChangeRequests: () => unsupported("listChangeRequests"),
36+ getChangeRequest: () => unsupported("getChangeRequest"),
37+ createChangeRequest: () => unsupported("createChangeRequest"),
38+ getRepositoryCloneUrls: () => Effect.succeed(CLONE_URLS),
39+ createRepository: () => Effect.succeed(CLONE_URLS),
40+ getDefaultBranch: () => Effect.succeed(null),
41+ checkoutChangeRequest: () => unsupported("checkoutChangeRequest"),
42+ ...overrides,
43+ };
44+}
45+
46+function processOutput(): ExecuteGitResult {
47+ return {
48+ exitCode: ChildProcessSpawner.ExitCode(0),
49+ stdout: "",
50+ stderr: "",
51+ stdoutTruncated: false,
52+ stderrTruncated: false,
53+ };
54+}
55+
56+function makeLayer(input: {
57+ readonly provider?: SourceControlProviderShape;
58+ readonly git?: Partial<GitVcsDriverShape>;
59+}) {
60+ return layer.pipe(
61+ Layer.provide(
62+ Layer.mock(SourceControlProviderRegistry)({
63+ get: () => Effect.succeed(input.provider ?? makeProvider()),
64+ }),
65+ ),
66+ Layer.provide(
67+ Layer.mock(GitVcsDriver)({
68+ execute: () => Effect.succeed(processOutput()),
69+ ensureRemote: () => Effect.succeed("origin"),
70+ pushCurrentBranch: () =>
71+ Effect.succeed({
72+ status: "pushed" as const,
73+ branch: "feature/remote-v1",
74+ upstreamBranch: "origin/feature/remote-v1",
75+ setUpstream: true,
76+ }),
77+ ...input.git,
78+ }),
79+ ),
80+ Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-repos-" })),
81+ Layer.provideMerge(NodeServices.layer),
82+ );
83+}
84+
85+it.effect("looks up repositories through the requested provider without search", () => {
86+ const calls: Array<{ cwd: string; repository: string }> = [];
87+ const provider = makeProvider({
88+ getRepositoryCloneUrls: (input) =>
89+ Effect.sync(() => {
90+ calls.push({ cwd: input.cwd, repository: input.repository });
91+ return CLONE_URLS;
92+ }),
93+ });
94+
95+ return Effect.gen(function* () {
96+ const service = yield* SourceControlRepositoryService;
97+ const result = yield* service.lookupRepository({
98+ provider: "github",
99+ repository: "octocat/t3code",
100+ cwd: "/workspace",
101+ });
102+
103+ assert.deepStrictEqual(result, { provider: "github", ...CLONE_URLS });
104+ assert.deepStrictEqual(calls, [{ cwd: "/workspace", repository: "octocat/t3code" }]);
105+ }).pipe(Effect.provide(makeLayer({ provider })));
106+});
107+
108+it.effect("clones a looked-up repository into the requested destination", () =>
109+ Effect.gen(function* () {
110+ const fs = yield* FileSystem.FileSystem;
111+ const parent = yield* fs.makeTempDirectoryScoped({
112+ prefix: "t3-source-control-clone-parent-",
113+ });
114+ const destinationPath = `${parent}/t3code`;
115+ const cloneCalls: Array<{ cwd: string; args: ReadonlyArray<string> }> = [];
116+
117+ yield* Effect.gen(function* () {
118+ const service = yield* SourceControlRepositoryService;
119+ const result = yield* service.cloneRepository({
120+ provider: "github",
121+ repository: "octocat/t3code",
122+ destinationPath,
123+ protocol: "https",
124+ });
125+
126+ assert.deepStrictEqual(result, {
127+ cwd: destinationPath,
128+ remoteUrl: CLONE_URLS.url,
129+ repository: { provider: "github", ...CLONE_URLS },
130+ });
131+ assert.deepStrictEqual(cloneCalls, [
132+ {
133+ cwd: parent,
134+ args: ["clone", CLONE_URLS.url, "t3code"],
135+ },
136+ ]);
137+ }).pipe(
138+ Effect.provide(
139+ makeLayer({
140+ git: {
141+ execute: (input) =>
142+ Effect.sync(() => {
143+ cloneCalls.push({ cwd: input.cwd, args: input.args });
144+ return processOutput();
145+ }),
146+ },
147+ }),
148+ ),
149+ );
150+ }).pipe(Effect.provide(NodeServices.layer)),
151+);
152+
153+it.effect("publishes by creating the repository, adding a remote, and pushing upstream", () => {
154+ const createCalls: Array<{ cwd: string; repository: string; visibility: string }> = [];
155+ const remoteCalls: Array<{ cwd: string; preferredName: string; url: string }> = [];
156+ const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = [];
157+ const provider = makeProvider({
158+ createRepository: (input) =>
159+ Effect.sync(() => {
160+ createCalls.push({
161+ cwd: input.cwd,
162+ repository: input.repository,
163+ visibility: input.visibility,
164+ });
165+ return CLONE_URLS;
166+ }),
167+ });
168+
169+ return Effect.gen(function* () {
170+ const service = yield* SourceControlRepositoryService;
171+ const result = yield* service.publishRepository({
172+ cwd: "/workspace",
173+ provider: "github",
174+ repository: "octocat/t3code",
175+ visibility: "private",
176+ remoteName: "origin",
177+ protocol: "ssh",
178+ });
179+
180+ assert.deepStrictEqual(result, {
181+ repository: { provider: "github", ...CLONE_URLS },
182+ remoteName: "origin",
183+ remoteUrl: CLONE_URLS.sshUrl,
184+ branch: "feature/remote-v1",
185+ upstreamBranch: "origin/feature/remote-v1",
186+ status: "pushed",
187+ });
188+ assert.deepStrictEqual(createCalls, [
189+ { cwd: "/workspace", repository: "octocat/t3code", visibility: "private" },
190+ ]);
191+ assert.deepStrictEqual(remoteCalls, [
192+ { cwd: "/workspace", preferredName: "origin", url: CLONE_URLS.sshUrl },
193+ ]);
194+ assert.deepStrictEqual(pushCalls, [{ cwd: "/workspace", remoteName: "origin" }]);
195+ }).pipe(
196+ Effect.provide(
197+ makeLayer({
198+ provider,
199+ git: {
200+ ensureRemote: (input) =>
201+ Effect.sync(() => {
202+ remoteCalls.push(input);
203+ return "origin";
204+ }),
205+ pushCurrentBranch: (cwd, _fallbackBranch, options) =>
206+ Effect.sync(() => {
207+ pushCalls.push({ cwd, remoteName: options?.remoteName });
208+ return {
209+ status: "pushed" as const,
210+ branch: "feature/remote-v1",
211+ upstreamBranch: "origin/feature/remote-v1",
212+ setUpstream: true,
213+ };
214+ }),
215+ },
216+ }),
217+ ),
218+ );
219+});
220+
221+it.effect("publishes to the remote name returned by ensureRemote", () => {
222+ const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = [];
223+
224+ return Effect.gen(function* () {
225+ const service = yield* SourceControlRepositoryService;
226+ const result = yield* service.publishRepository({
227+ cwd: "/workspace",
228+ provider: "github",
229+ repository: "octocat/t3code",
230+ visibility: "private",
231+ remoteName: "origin",
232+ protocol: "ssh",
233+ });
234+
235+ assert.equal(result.remoteName, "origin-1");
236+ assert.deepStrictEqual(pushCalls, [{ cwd: "/workspace", remoteName: "origin-1" }]);
237+ }).pipe(
238+ Effect.provide(
239+ makeLayer({
240+ git: {
241+ ensureRemote: () => Effect.succeed("origin-1"),
242+ pushCurrentBranch: (cwd, _fallbackBranch, options) =>
243+ Effect.sync(() => {
244+ pushCalls.push({ cwd, remoteName: options?.remoteName });
245+ return {
246+ status: "pushed" as const,
247+ branch: "feature/remote-v1",
248+ upstreamBranch: `${options?.remoteName ?? "missing"}/feature/remote-v1`,
249+ setUpstream: true,
250+ };
251+ }),
252+ },
253+ }),
254+ ),
255+ );
256+});
257+
258+it.effect("publish succeeds with status remote_added when the local repo has no commits", () => {
259+ let pushCalls = 0;
260+ return Effect.gen(function* () {
261+ const service = yield* SourceControlRepositoryService;
262+ const result = yield* service.publishRepository({
263+ cwd: "/workspace",
264+ provider: "github",
265+ repository: "octocat/t3code",
266+ visibility: "private",
267+ remoteName: "origin",
268+ protocol: "ssh",
269+ });
270+
271+ assert.deepStrictEqual(result, {
272+ repository: { provider: "github", ...CLONE_URLS },
273+ remoteName: "origin",
274+ remoteUrl: CLONE_URLS.sshUrl,
275+ branch: "main",
276+ status: "remote_added",
277+ });
278+ assert.strictEqual(pushCalls, 0);
279+ }).pipe(
280+ Effect.provide(
281+ makeLayer({
282+ git: {
283+ execute: (input) =>
284+ input.args[0] === "rev-parse"
285+ ? Effect.fail(
286+ new GitCommandError({
287+ operation: input.operation,
288+ command: "git rev-parse --verify HEAD",
289+ cwd: input.cwd,
290+ detail: "fatal: Needed a single revision",
291+ }),
292+ )
293+ : Effect.succeed(processOutput()),
294+ statusDetails: () =>
295+ Effect.succeed({
296+ isRepo: true,
297+ hasOriginRemote: true,
298+ isDefaultBranch: true,
299+ branch: "main",
300+ upstreamRef: null,
301+ hasWorkingTreeChanges: false,
302+ workingTree: { files: [], insertions: 0, deletions: 0 },
303+ hasUpstream: false,
304+ aheadCount: 0,
305+ behindCount: 0,
306+ aheadOfDefaultCount: 0,
307+ }),
308+ pushCurrentBranch: () =>
309+ Effect.sync(() => {
310+ pushCalls += 1;
311+ return {
312+ status: "pushed" as const,
313+ branch: "main",
314+ upstreamBranch: "origin/main",
315+ setUpstream: true,
316+ };
317+ }),
318+ },
319+ }),
320+ ),
321+ );
322+});
apps/server/src/sourceControl/SourceControlRepositoryService.ts+320−0
@@ -0,0 +1,320 @@
1+import OS from "node:os";
2+import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect";
3+
4+import {
5+ SourceControlRepositoryError,
6+ type SourceControlCloneRepositoryInput,
7+ type SourceControlCloneRepositoryResult,
8+ type SourceControlCloneProtocol,
9+ type SourceControlProviderKind,
10+ type SourceControlPublishRepositoryInput,
11+ type SourceControlPublishRepositoryResult,
12+ type SourceControlRepositoryCloneUrls,
13+ type SourceControlRepositoryInfo,
14+ type SourceControlRepositoryLookupInput,
15+} from "@t3tools/contracts";
16+
17+import { ServerConfig } from "../config.ts";
18+import { GitVcsDriver } from "../vcs/GitVcsDriver.ts";
19+import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts";
20+
21+export interface SourceControlRepositoryServiceShape {
22+ readonly lookupRepository: (
23+ input: SourceControlRepositoryLookupInput,
24+ ) => Effect.Effect<SourceControlRepositoryInfo, SourceControlRepositoryError>;
25+ readonly cloneRepository: (
26+ input: SourceControlCloneRepositoryInput,
27+ ) => Effect.Effect<SourceControlCloneRepositoryResult, SourceControlRepositoryError>;
28+ readonly publishRepository: (
29+ input: SourceControlPublishRepositoryInput,
30+ ) => Effect.Effect<SourceControlPublishRepositoryResult, SourceControlRepositoryError>;
31+}
32+
33+export class SourceControlRepositoryService extends Context.Service<
34+ SourceControlRepositoryService,
35+ SourceControlRepositoryServiceShape
36+>()("t3/source-control/SourceControlRepositoryService") {}
37+
38+function detailFromUnknown(cause: unknown): string {
39+ if (typeof cause === "object" && cause !== null) {
40+ if ("detail" in cause && typeof cause.detail === "string" && cause.detail.length > 0) {
41+ return cause.detail;
42+ }
43+ if ("message" in cause && typeof cause.message === "string" && cause.message.length > 0) {
44+ return cause.message;
45+ }
46+ }
47+
48+ return "An unexpected source control error occurred.";
49+}
50+
51+function repositoryError(input: {
52+ readonly operation: string;
53+ readonly provider: SourceControlProviderKind;
54+ readonly detail: string;
55+ readonly cause?: unknown;
56+}): SourceControlRepositoryError {
57+ return new SourceControlRepositoryError({
58+ provider: input.provider,
59+ operation: input.operation,
60+ detail: input.detail,
61+ ...(input.cause === undefined ? {} : { cause: input.cause }),
62+ });
63+}
64+
65+function mapRepositoryError(operation: string, provider: SourceControlProviderKind) {
66+ return Effect.mapError((cause: unknown) =>
67+ Schema.is(SourceControlRepositoryError)(cause)
68+ ? cause
69+ : repositoryError({
70+ operation,
71+ provider,
72+ detail: detailFromUnknown(cause),
73+ cause,
74+ }),
75+ );
76+}
77+
78+function toRepositoryInfo(
79+ provider: SourceControlProviderKind,
80+ urls: SourceControlRepositoryCloneUrls,
81+): SourceControlRepositoryInfo {
82+ return {
83+ provider,
84+ nameWithOwner: urls.nameWithOwner,
85+ url: urls.url,
86+ sshUrl: urls.sshUrl,
87+ };
88+}
89+
90+function selectRemoteUrl(
91+ urls: SourceControlRepositoryCloneUrls,
92+ protocol: SourceControlCloneProtocol | undefined,
93+): string {
94+ switch (protocol ?? "auto") {
95+ case "https":
96+ return urls.url;
97+ case "ssh":
98+ case "auto":
99+ return urls.sshUrl;
100+ }
101+}
102+
103+function expandHomePath(input: string, path: Path.Path): string {
104+ if (input === "~") {
105+ return OS.homedir();
106+ }
107+ if (input.startsWith("~/") || input.startsWith("~\\")) {
108+ return path.join(OS.homedir(), input.slice(2));
109+ }
110+ return input;
111+}
112+
113+export const make = Effect.fn("makeSourceControlRepositoryService")(function* () {
114+ const config = yield* ServerConfig;
115+ const fileSystem = yield* FileSystem.FileSystem;
116+ const git = yield* GitVcsDriver;
117+ const path = yield* Path.Path;
118+ const providers = yield* SourceControlProviderRegistry;
119+
120+ const ensureConcreteProvider = (input: {
121+ readonly operation: string;
122+ readonly provider: SourceControlProviderKind;
123+ }) => {
124+ if (input.provider !== "unknown") {
125+ return Effect.succeed(input.provider);
126+ }
127+
128+ return Effect.fail(
129+ repositoryError({
130+ operation: input.operation,
131+ provider: input.provider,
132+ detail: "Choose a source control provider before continuing.",
133+ }),
134+ );
135+ };
136+
137+ const lookupRepository = Effect.fn("SourceControlRepositoryService.lookupRepository")(function* (
138+ input: SourceControlRepositoryLookupInput,
139+ ) {
140+ const providerKind = yield* ensureConcreteProvider({
141+ operation: "lookupRepository",
142+ provider: input.provider,
143+ });
144+ const provider = yield* providers.get(providerKind);
145+ const urls = yield* provider.getRepositoryCloneUrls({
146+ cwd: input.cwd ?? config.cwd,
147+ repository: input.repository.trim(),
148+ });
149+ return toRepositoryInfo(providerKind, urls);
150+ });
151+
152+ const normalizeDestinationPath = Effect.fn("SourceControlRepositoryService.normalizeDestination")(
153+ function* (destinationPath: string) {
154+ const trimmed = destinationPath.trim();
155+ if (trimmed.length === 0) {
156+ return yield* Effect.fail(
157+ repositoryError({
158+ operation: "cloneRepository",
159+ provider: "unknown",
160+ detail: "Choose a destination path before cloning.",
161+ }),
162+ );
163+ }
164+
165+ return path.resolve(expandHomePath(trimmed, path));
166+ },
167+ );
168+
169+ const prepareDestination = Effect.fn("SourceControlRepositoryService.prepareDestination")(
170+ function* (destinationPath: string) {
171+ const normalizedDestination = yield* normalizeDestinationPath(destinationPath);
172+ if (yield* fileSystem.exists(normalizedDestination).pipe(Effect.orElseSucceed(() => false))) {
173+ const entries = yield* fileSystem
174+ .readDirectory(normalizedDestination, { recursive: false })
175+ .pipe(
176+ Effect.mapError((cause) =>
177+ repositoryError({
178+ operation: "cloneRepository",
179+ provider: "unknown",
180+ detail: "Destination path already exists and is not a directory.",
181+ cause,
182+ }),
183+ ),
184+ );
185+ if (entries.length > 0) {
186+ return yield* Effect.fail(
187+ repositoryError({
188+ operation: "cloneRepository",
189+ provider: "unknown",
190+ detail: "Destination path already exists and is not empty.",
191+ }),
192+ );
193+ }
194+ } else {
195+ yield* fileSystem.makeDirectory(path.dirname(normalizedDestination), { recursive: true });
196+ }
197+
198+ return {
199+ destinationPath: normalizedDestination,
200+ parentPath: path.dirname(normalizedDestination),
201+ directoryName: path.basename(normalizedDestination),
202+ };
203+ },
204+ );
205+
206+ const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* (
207+ input: SourceControlCloneRepositoryInput,
208+ ) {
209+ const preparedDestination = yield* prepareDestination(input.destinationPath);
210+ let repository: SourceControlRepositoryInfo | null = null;
211+ let remoteUrl = input.remoteUrl?.trim() ?? null;
212+ let provider: SourceControlProviderKind = input.provider ?? "unknown";
213+
214+ if (input.provider && input.repository) {
215+ repository = yield* lookupRepository({
216+ provider: input.provider,
217+ repository: input.repository,
218+ cwd: preparedDestination.parentPath,
219+ });
220+ remoteUrl = selectRemoteUrl(repository, input.protocol);
221+ provider = input.provider;
222+ }
223+
224+ if (!remoteUrl) {
225+ return yield* Effect.fail(
226+ repositoryError({
227+ operation: "cloneRepository",
228+ provider,
229+ detail: "Enter a repository path or clone URL before cloning.",
230+ }),
231+ );
232+ }
233+
234+ yield* git.execute({
235+ operation: "SourceControlRepositoryService.cloneRepository",
236+ cwd: preparedDestination.parentPath,
237+ args: ["clone", remoteUrl, preparedDestination.directoryName],
238+ timeoutMs: 120_000,
239+ maxOutputBytes: 256 * 1024,
240+ });
241+
242+ return {
243+ cwd: preparedDestination.destinationPath,
244+ remoteUrl,
245+ repository,
246+ };
247+ });
248+
249+ const publishRepository = Effect.fn("SourceControlRepositoryService.publishRepository")(
250+ function* (input: SourceControlPublishRepositoryInput) {
251+ const providerKind = yield* ensureConcreteProvider({
252+ operation: "publishRepository",
253+ provider: input.provider,
254+ });
255+ const provider = yield* providers.get(providerKind);
256+ const urls = yield* provider.createRepository({
257+ cwd: input.cwd,
258+ repository: input.repository.trim(),
259+ visibility: input.visibility,
260+ });
261+ const remoteUrl = selectRemoteUrl(urls, input.protocol);
262+ const remoteName = yield* git.ensureRemote({
263+ cwd: input.cwd,
264+ preferredName: input.remoteName?.trim() || "origin",
265+ url: remoteUrl,
266+ });
267+
268+ // An empty local repo (no commits) would make `git push HEAD:...` fail
269+ // with an opaque "src refspec HEAD does not match any". Treat this as a
270+ // partial success: the remote was created and wired up, but there is
271+ // nothing to push yet.
272+ const hasCommits = yield* git
273+ .execute({
274+ operation: "SourceControlRepositoryService.publishRepository.headCheck",
275+ cwd: input.cwd,
276+ args: ["rev-parse", "--verify", "HEAD"],
277+ })
278+ .pipe(
279+ Effect.map(() => true),
280+ Effect.catch(() => Effect.succeed(false)),
281+ );
282+ if (!hasCommits) {
283+ const details = yield* git
284+ .statusDetails(input.cwd)
285+ .pipe(Effect.catch(() => Effect.succeed(null)));
286+ return {
287+ repository: toRepositoryInfo(providerKind, urls),
288+ remoteName,
289+ remoteUrl,
290+ branch: details?.branch ?? "main",
291+ status: "remote_added" as const,
292+ };
293+ }
294+
295+ const pushResult = yield* git.pushCurrentBranch(input.cwd, null, { remoteName });
296+
297+ return {
298+ repository: toRepositoryInfo(providerKind, urls),
299+ remoteName,
300+ remoteUrl,
301+ branch: pushResult.branch,
302+ ...(pushResult.upstreamBranch ? { upstreamBranch: pushResult.upstreamBranch } : {}),
303+ status: "pushed" as const,
304+ };
305+ },
306+ );
307+
308+ return SourceControlRepositoryService.of({
309+ lookupRepository: (input) =>
310+ lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)),
311+ cloneRepository: (input) =>
312+ cloneRepository(input).pipe(
313+ mapRepositoryError("cloneRepository", input.provider ?? "unknown"),
314+ ),
315+ publishRepository: (input) =>
316+ publishRepository(input).pipe(mapRepositoryError("publishRepository", input.provider)),
317+ });
318+});
319+
320+export const layer = Layer.effect(SourceControlRepositoryService, make());
apps/server/src/vcs/GitVcsDriver.ts+1−0
@@ -165,6 +165,7 @@ export interface GitVcsDriverShape { · 164 unmodified lines
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−0
@@ -326,5 +326,41 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { · 325 unmodified lines
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* · 1430 unmodified lines
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* · 5 unmodified lines
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−0
@@ -55,6 +55,7 @@ import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentit · 54 unmodified lines
5555 import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
5656 import { ServerAuth } from "./auth/Services/ServerAuth.ts";
5757 import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts";
58+import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts";
5859 import * as VcsProcess from "./vcs/VcsProcess.ts";
5960 import {
6061 BootstrapCredentialService,
@@ -154,6 +155,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => · 93 unmodified lines
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) => · 634 unmodified lines
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: { · 205 unmodified lines
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)", () => { · 4045 unmodified lines
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)", () => { · 45 unmodified lines
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)", () => { · 66 unmodified lines
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)", () => { · 40 unmodified lines
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)", () => { · 346 unmodified lines
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)", () => { · 209 unmodified lines
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 { · 6 unmodified lines
77 type FilesystemBrowseResult,
88 type ProjectId,
99 ProviderInstanceId,
10+ type SourceControlProviderKind,
11+ type SourceControlRepositoryInfo,
1012 } from "@t3tools/contracts";
1113 import { useQuery, useQueryClient } from "@tanstack/react-query";
1214 import { useNavigate, useParams } from "@tanstack/react-router";
@@ -17,6 +19,7 @@ import { · 4 unmodified lines
1719 CornerLeftUpIcon,
1820 FolderIcon,
1921 FolderPlusIcon,
22+ LinkIcon,
2023 MessageSquareIcon,
2124 SettingsIcon,
2225 SquarePenIcon,
@@ -89,6 +92,7 @@ import { · 66 unmodified lines
8992 } from "./CommandPalette.logic";
9093 import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
9194 import { CommandPaletteResults } from "./CommandPaletteResults";
95+import { GitHubIcon, GitLabIcon } from "./Icons";
9296 import { ProjectFavicon } from "./ProjectFavicon";
9397 import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
9498 import { useServerKeybindings } from "../rpc/serverState";
@@ -139,6 +143,70 @@ interface AddProjectEnvironmentOption { · 44 unmodified lines
139143 readonly isPrimary: boolean;
140144 }
141145
146+type AddProjectRemoteProviderKind = Extract<SourceControlProviderKind, "github" | "gitlab">;
147+type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
148+
149+type AddProjectCloneFlow =
150+ | {
151+ readonly step: "repository";
152+ readonly environmentId: EnvironmentId;
153+ readonly source: AddProjectRemoteSource;
154+ }
155+ | {
156+ readonly step: "confirm";
157+ readonly environmentId: EnvironmentId;
158+ readonly source: AddProjectRemoteSource;
159+ readonly repositoryInput: string;
160+ readonly repository: SourceControlRepositoryInfo | null;
161+ readonly remoteUrl: string;
162+ };
163+
164+const REMOTE_PROJECT_SOURCES: ReadonlyArray<AddProjectRemoteSource> = ["github", "gitlab", "url"];
165+
166+function remoteProjectSourceLabel(source: AddProjectRemoteSource): string {
167+ switch (source) {
168+ case "github":
169+ return "GitHub";
170+ case "gitlab":
171+ return "GitLab";
172+ case "url":
173+ return "Git URL";
174+ }
175+}
176+
177+function remoteProjectSourceProvider(
178+ source: AddProjectRemoteSource,
179+): AddProjectRemoteProviderKind | null {
180+ return source === "url" ? null : source;
181+}
182+
183+function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: string): ReactNode {
184+ switch (source) {
185+ case "github":
186+ return <GitHubIcon className={className} />;
187+ case "gitlab":
188+ return <GitLabIcon className={className} />;
189+ case "url":
190+ return <LinkIcon className={className} />;
191+ }
192+}
193+
194+function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string | null {
195+ if (!flow) return null;
196+ if (flow.step === "confirm") return null;
197+ if (flow.source === "url") {
198+ return "Enter Git clone URL";
199+ }
200+ return `Enter ${remoteProjectSourceLabel(flow.source)} repository (owner/repo)`;
201+}
202+
203+function errorMessage(error: unknown): string {
204+ if (error instanceof Error && error.message.trim().length > 0) {
205+ return error.message;
206+ }
207+ return "An error occurred.";
208+}
209+
142210 export 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() { · 82 unmodified lines
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() { · 62 unmodified lines
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() { · 239 unmodified lines
541615 }
542616
543617 function popView(): void {
618+ setAddProjectCloneFlow(null);
544619 if (viewStack.length <= 1) {
545620 setAddProjectEnvironmentId(null);
546621 }
@@ -560,6 +635,7 @@ function OpenCommandPaletteDialog() { · 13 unmodified lines
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() { · 3 unmodified lines
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() { · 20 unmodified lines
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() { · 73 unmodified lines
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() { · 117 unmodified lines
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() { · 32 unmodified lines
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() { · 5 unmodified lines
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() { · 31 unmodified lines
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() { · 1 unmodified line
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() { · 70 unmodified lines
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() { · 3 unmodified lines
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() { · 18 unmodified lines
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() { · 3 unmodified lines
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() { · 1 unmodified line
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() { · 1 unmodified line
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() { · 7 unmodified lines
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−0
@@ -100,12 +100,14 @@ vi.mock("~/editorPreferences", () => ({ · 99 unmodified lines
100100 vi.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
111113 vi.mock("~/lib/gitStatusState", () => ({
apps/web/src/components/GitActionsControl.logic.test.ts+5−22
@@ -730,7 +730,7 @@ describe("when: ref has no upstream configured", () => { · 729 unmodified lines
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", () => { · 5 unmodified lines
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", () => { · 27 unmodified lines
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", () => { · 8 unmodified lines
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 { · 25 unmodified lines
2626 export 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( · 89 unmodified lines
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( · 79 unmodified lines
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 { · 2 unmodified lines
33 GitActionProgressEvent,
44 GitRunStackedActionResult,
55 GitStackedAction,
6+ SourceControlCloneProtocol,
7+ SourceControlProviderKind,
8+ SourceControlPublishRepositoryResult,
9+ SourceControlRepositoryVisibility,
610 VcsStatusResult,
711 } from "@t3tools/contracts";
812 import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
13+import { Option } from "effect";
914 import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
10−import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
15+import {
16+ CheckIcon,
17+ ChevronDownIcon,
18+ CloudUploadIcon,
19+ ExternalLinkIcon,
20+ GitCommitIcon,
21+ InfoIcon,
22+ LockIcon,
23+ GlobeIcon,
24+} from "lucide-react";
25+import { Radio as RadioPrimitive } from "@base-ui/react/radio";
26+import { GitHubIcon, GitLabIcon } from "~/components/Icons";
27+import { RadioGroup } from "~/components/ui/radio-group";
28+import { Spinner } from "~/components/ui/spinner";
29+import { cn } from "~/lib/utils";
1130 import {
1231 buildGitActionProgressStages,
1332 buildMenuItems,
@@ -33,9 +52,18 @@ import { · 19 unmodified lines
3352 DialogTitle,
3453 } from "~/components/ui/dialog";
3554 import { Group, GroupSeparator } from "~/components/ui/group";
55+import { Input } from "~/components/ui/input";
3656 import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
3757 import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
3858 import { ScrollArea } from "~/components/ui/scroll-area";
59+import {
60+ Select,
61+ SelectGroup,
62+ SelectItem,
63+ SelectPopup,
64+ SelectTrigger,
65+ SelectValue,
66+} from "~/components/ui/select";
3967 import { Textarea } from "~/components/ui/textarea";
4068 import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast";
4169 import { openInPreferredEditor } from "~/editorPreferences";
@@ -44,8 +72,10 @@ import { · 2 unmodified lines
4472 gitMutationKeys,
4573 gitPullMutationOptions,
4674 gitRunStackedActionMutationOptions,
75+ sourceControlPublishRepositoryMutationOptions,
4776 } from "~/lib/gitReactQuery";
4877 import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";
78+import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState";
4979 import { newCommandId, randomUUID } from "~/lib/utils";
5080 import { resolvePathLinkTarget } from "~/terminal-links";
5181 import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
@@ -70,6 +100,8 @@ interface PendingDefaultBranchAction { · 18 unmodified lines
70100 filePaths?: string[];
71101 }
72102
103+type PublishProviderKind = Extract<SourceControlProviderKind, "github" | "gitlab">;
104+
73105 type GitActionToastId = ReturnType<typeof toastManager.add>;
74106
75107 interface ActiveGitActionProgress {
@@ -211,6 +243,7 @@ function GitQuickActionIcon({ · 135 unmodified lines
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({ · 35 unmodified lines
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({ · 105 unmodified lines
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({ · 1 unmodified line
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({ · 60 unmodified lines
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({ · 340 unmodified lines
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({ · 91 unmodified lines
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({ · 108 unmodified lines
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({ · 186 unmodified lines
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>> = { · 56 unmodified lines
5757 jj: JujutsuIcon,
5858 };
5959
60+const SOURCE_CONTROL_SKELETON_ROWS = ["primary", "secondary"] as const;
61+
6062 function optionLabel(value: Option.Option<string>): string | null {
6163 return Option.getOrNull(value);
6264 }
@@ -157,6 +159,11 @@ function itemSummary({ · 94 unmodified lines
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({ · 69 unmodified lines
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−0
@@ -23,6 +23,11 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { · 22 unmodified lines
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−0
@@ -2,6 +2,7 @@ import { · 1 unmodified line
22 type EnvironmentId,
33 type GitActionProgressEvent,
44 type GitStackedAction,
5+ type SourceControlPublishRepositoryInput,
56 type ThreadId,
67 } from "@t3tools/contracts";
78 import {
@@ -36,6 +37,8 @@ export const gitMutationKeys = { · 28 unmodified lines
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
4144 export function invalidateGitQueries(
@@ -228,6 +231,26 @@ export function gitPullMutationOptions(input: { · 186 unmodified lines
228231 });
229232 }
230233
234+export function sourceControlPublishRepositoryMutationOptions(input: {
235+ environmentId: EnvironmentId | null;
236+ cwd: string | null;
237+ queryClient: QueryClient;
238+}) {
239+ return mutationOptions({
240+ mutationKey: gitMutationKeys.publishRepository(input.environmentId, input.cwd),
241+ mutationFn: async (args: Omit<SourceControlPublishRepositoryInput, "cwd">) => {
242+ if (!input.cwd || !input.environmentId) {
243+ throw new Error("Repository publishing is unavailable.");
244+ }
245+ const api = ensureEnvironmentApi(input.environmentId);
246+ return api.sourceControl.publishRepository({ cwd: input.cwd, ...args });
247+ },
248+ onSuccess: async () => {
249+ await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
250+ },
251+ });
252+}
253+
231254 /**
232255 * @deprecated Use a VCS-named mutation helper once the UI naming migration lands.
233256 */
apps/web/src/localApi.test.ts+5−0
@@ -56,6 +56,11 @@ const rpcClientMock = { · 55 unmodified lines
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−0
@@ -71,6 +71,11 @@ export interface WsRpcClient { · 70 unmodified lines
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 { · 88 unmodified lines
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 { · 54 unmodified lines
5555 import type { EnvironmentId } from "./baseSchemas.ts";
5656 import { EditorId } from "./editor.ts";
5757 import { ServerSettings, type ClientSettings, type ServerSettingsPatch } from "./settings.ts";
58−import type { SourceControlDiscoveryResult } from "./sourceControl.ts";
58+import type {
59+ SourceControlCloneRepositoryInput,
60+ SourceControlCloneRepositoryResult,
61+ SourceControlDiscoveryResult,
62+ SourceControlPublishRepositoryInput,
63+ SourceControlPublishRepositoryResult,
64+ SourceControlRepositoryInfo,
65+ SourceControlRepositoryLookupInput,
66+} from "./sourceControl.ts";
5967
6068 export interface ContextMenuItem<T extends string = string> {
6169 id: T;
@@ -258,6 +266,17 @@ export interface EnvironmentApi { · 196 unmodified lines
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 { · 76 unmodified lines
7777 ServerUpsertKeybindingResult,
7878 } from "./server.ts";
7979 import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts";
80−import { SourceControlDiscoveryResult } from "./sourceControl.ts";
80+import {
81+ SourceControlCloneRepositoryInput,
82+ SourceControlCloneRepositoryResult,
83+ SourceControlDiscoveryResult,
84+ SourceControlPublishRepositoryInput,
85+ SourceControlPublishRepositoryResult,
86+ SourceControlRepositoryError,
87+ SourceControlRepositoryInfo,
88+ SourceControlRepositoryLookupInput,
89+} from "./sourceControl.ts";
8190 import { VcsError } from "./vcs.ts";
8291
8392 export const WS_METHODS = {
@@ -125,6 +134,11 @@ export const WS_METHODS = { · 41 unmodified lines
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 · 44 unmodified lines
175189 success: SourceControlDiscoveryResult,
176190 });
177191
192+export const WsSourceControlLookupRepositoryRpc = Rpc.make(
193+ WS_METHODS.sourceControlLookupRepository,
194+ {
195+ payload: SourceControlRepositoryLookupInput,
196+ success: SourceControlRepositoryInfo,
197+ error: SourceControlRepositoryError,
198+ },
199+);
200+
201+export const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlCloneRepository, {
202+ payload: SourceControlCloneRepositoryInput,
203+ success: SourceControlCloneRepositoryResult,
204+ error: SourceControlRepositoryError,
205+});
206+
207+export const WsSourceControlPublishRepositoryRpc = Rpc.make(
208+ WS_METHODS.sourceControlPublishRepository,
209+ {
210+ payload: SourceControlPublishRepositoryInput,
211+ success: SourceControlPublishRepositoryResult,
212+ error: SourceControlRepositoryError,
213+ },
214+);
215+
178216 export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, {
179217 payload: ProjectSearchEntriesInput,
180218 success: ProjectSearchEntriesResult,
@@ -381,6 +419,9 @@ export const WsRpcGroup = RpcGroup.make( · 200 unmodified lines
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({ · 42 unmodified lines
4343 });
4444 export type SourceControlRepositoryCloneUrls = typeof SourceControlRepositoryCloneUrls.Type;
4545
46+export const SourceControlRepositoryVisibility = Schema.Literals(["private", "public"]);
47+export type SourceControlRepositoryVisibility = typeof SourceControlRepositoryVisibility.Type;
48+
49+export const SourceControlCloneProtocol = Schema.Literals(["auto", "ssh", "https"]);
50+export type SourceControlCloneProtocol = typeof SourceControlCloneProtocol.Type;
51+
52+export const SourceControlRepositoryInfo = Schema.Struct({
53+ provider: SourceControlProviderKind,
54+ nameWithOwner: TrimmedNonEmptyString,
55+ url: TrimmedNonEmptyString,
56+ sshUrl: TrimmedNonEmptyString,
57+});
58+export type SourceControlRepositoryInfo = typeof SourceControlRepositoryInfo.Type;
59+
60+export const SourceControlRepositoryLookupInput = Schema.Struct({
61+ provider: SourceControlProviderKind,
62+ repository: TrimmedNonEmptyString,
63+ cwd: Schema.optional(TrimmedNonEmptyString),
64+});
65+export type SourceControlRepositoryLookupInput = typeof SourceControlRepositoryLookupInput.Type;
66+
67+export const SourceControlCloneRepositoryInput = Schema.Struct({
68+ provider: Schema.optional(SourceControlProviderKind),
69+ repository: Schema.optional(TrimmedNonEmptyString),
70+ remoteUrl: Schema.optional(TrimmedNonEmptyString),
71+ destinationPath: TrimmedNonEmptyString,
72+ protocol: Schema.optional(SourceControlCloneProtocol),
73+});
74+export type SourceControlCloneRepositoryInput = typeof SourceControlCloneRepositoryInput.Type;
75+
76+export const SourceControlCloneRepositoryResult = Schema.Struct({
77+ cwd: TrimmedNonEmptyString,
78+ remoteUrl: TrimmedNonEmptyString,
79+ repository: Schema.NullOr(SourceControlRepositoryInfo),
80+});
81+export type SourceControlCloneRepositoryResult = typeof SourceControlCloneRepositoryResult.Type;
82+
83+export const SourceControlPublishRepositoryInput = Schema.Struct({
84+ cwd: TrimmedNonEmptyString,
85+ provider: SourceControlProviderKind,
86+ repository: TrimmedNonEmptyString,
87+ visibility: SourceControlRepositoryVisibility,
88+ remoteName: Schema.optional(TrimmedNonEmptyString),
89+ protocol: Schema.optional(SourceControlCloneProtocol),
90+});
91+export type SourceControlPublishRepositoryInput = typeof SourceControlPublishRepositoryInput.Type;
92+
93+export const SourceControlPublishStatus = Schema.Literals(["pushed", "remote_added"]);
94+export type SourceControlPublishStatus = typeof SourceControlPublishStatus.Type;
95+
96+export const SourceControlPublishRepositoryResult = Schema.Struct({
97+ repository: SourceControlRepositoryInfo,
98+ remoteName: TrimmedNonEmptyString,
99+ remoteUrl: TrimmedNonEmptyString,
100+ branch: TrimmedNonEmptyString,
101+ upstreamBranch: Schema.optional(TrimmedNonEmptyString),
102+ status: SourceControlPublishStatus,
103+});
104+export type SourceControlPublishRepositoryResult = typeof SourceControlPublishRepositoryResult.Type;
105+
46106 export const SourceControlDiscoveryStatus = Schema.Literals(["available", "missing"]);
47107 export type SourceControlDiscoveryStatus = typeof SourceControlDiscoveryStatus.Type;
48108
@@ -63,7 +123,7 @@ export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type; · 14 unmodified lines
63123
64124 const 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 · 39 unmodified lines
109169 return `Source control provider ${this.provider} failed in ${this.operation}: ${this.detail}`;
110170 }
111171 }
172+
173+export class SourceControlRepositoryError extends Schema.TaggedErrorClass<SourceControlRepositoryError>()(
174+ "SourceControlRepositoryError",
175+ {
176+ provider: SourceControlProviderKind,
177+ operation: Schema.String,
178+ detail: Schema.String,
179+ cause: Schema.optional(Schema.Defect),
180+ },
181+) {
182+ override get message(): string {
183+ return `Source control repository operation ${this.operation} failed for ${this.provider}: ${this.detail}`;
184+ }
185+}