authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-10 12:07:00-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 12:07:00-07:00
logc8a603307c2d50f8fb9d302f4b51f20fd9f91b33
tree9fc9a44bcc54e0ae1240e52dd628e5560914d5bc
parent7bc732804222946c0c9b23af057484e4412698fc
signature Signed by PGP key B5690EEEBB952194

refactor(client-runtime): add project clone sources come from host definitions (#17756)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

6 files changed, 85 insertions(+), 136 deletions(-)

apps/mobile/src/components/SourceControlIcon.tsx+12-4
......@@ -1,12 +1,11 @@
1import Svg, { Circle, Defs, G, LinearGradient, Path, Stop } from "react-native-svg";
1import Svg, { Circle, Defs, G, LinearGradient, Path, Rect, Stop } from "react-native-svg";
22import { withUniwind } from "uniwind";
33
44const ThemedSvg = withUniwind(Svg);
55
6export type SourceControlIconKind = "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops";
7
6/** Draws a host's glyph by its client definition's `icon` key; null for a key it has no art for. */
87export function SourceControlIcon(props: {
9 readonly kind: SourceControlIconKind;
8 readonly kind: string;
109 readonly size?: number;
1110 readonly color?: string;
1211 readonly colorClassName?: string;
......@@ -252,5 +251,14 @@ export function SourceControlIcon(props: {
252251 />
253252 </Svg>
254253 );
254 case "gitcafe":
255 // GitCafe's own mark from https://git.cafe/favicon.svg, inset like the web icon.
256 return (
257 <Svg width={size} height={size} viewBox="0 0 64 64">
258 <Rect x={4.8} y={4.8} width={54.4} height={54.4} fill="#a78bfa" />
259 </Svg>
260 );
261 default:
262 return null;
255263 }
256264}
apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx+5-9
......@@ -1,6 +1,9 @@
11import type { StaticScreenProps } from "@react-navigation/native";
22import { NativeStackScreenOptions } from "../../native/StackHeader";
3import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime/operations/projects";
3import {
4 addProjectRemoteSourceLabel,
5 parseAddProjectRemoteSource,
6} from "@t3tools/client-runtime/operations/projects";
47
58import { AddProjectRepositoryScreen } from "./AddProjectScreen";
69
......@@ -14,14 +17,7 @@ export function AddProjectRepositoryRoute({
1417}: StaticScreenProps<AddProjectRepositoryRouteParams>) {
1518 const params = route.params ?? {};
1619 const source = Array.isArray(params.source) ? params.source[0] : params.source;
17 const title =
18 source === "github" ||
19 source === "gitlab" ||
20 source === "forgejo" ||
21 source === "bitbucket" ||
22 source === "azure-devops"
23 ? addProjectRemoteSourceLabel(source)
24 : "Git URL";
20 const title = addProjectRemoteSourceLabel(parseAddProjectRemoteSource(source));
2521
2622 return (
2723 <>
apps/mobile/src/features/projects/AddProjectScreen.tsx+10-19
......@@ -1,4 +1,3 @@
1import { SourceControlProviderKind } from "@t3tools/contracts";
21import { MaterialListRow } from "../../components/MaterialListRow";
32import { SettingsScreen } from "../settings/components/SettingsScreen";
43import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
......@@ -20,6 +19,7 @@ import {
2019 getNewProjectGitHubTarget,
2120 getNewProjectPathPreview,
2221 normalizePastedCloneUrl,
22 parseAddProjectRemoteSource,
2323 resolveAddProjectPath,
2424 sortAddProjectProviderSources,
2525 type AddProjectRemoteSource,
......@@ -49,6 +49,7 @@ import {
4949 type EnvironmentMachineKind,
5050 ProjectId,
5151 resolveEnvironmentMachineKind,
52 SourceControlProviderKind,
5253} from "@t3tools/contracts";
5354import { CommonActions, StackActions, useNavigation } from "@react-navigation/native";
5455import { SymbolView } from "../../components/AppSymbol";
......@@ -71,6 +72,7 @@ import { AppText as Text, AppTextInput as TextInput } from "../../components/App
7172import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
7273import { ErrorBanner } from "../../components/ErrorBanner";
7374import { SourceControlIcon } from "../../components/SourceControlIcon";
75import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
7476import { ThemedSwitch } from "../../components/ThemedSwitch";
7577import { uuidv4 } from "../../lib/uuid";
7678import { useAtomCommand } from "../../state/use-atom-command";
......@@ -123,18 +125,7 @@ function stringParam(value: string | string[] | undefined): string | null {
123125}
124126
125127function sourceFromParam(value: string | string[] | undefined): AddProjectRemoteSource {
126 const source = stringParam(value);
127 if (
128 source === "url" ||
129 source === "github" ||
130 source === "gitlab" ||
131 source === "forgejo" ||
132 source === "bitbucket" ||
133 source === "azure-devops"
134 ) {
135 return source;
136 }
137 return "url";
128 return parseAddProjectRemoteSource(stringParam(value));
138129}
139130
140131function SectionTitle(props: { readonly children: string }) {
......@@ -499,7 +490,7 @@ function SourceControlRow(props: {
499490 />
500491 ) : (
501492 <SourceControlIcon
502 kind={props.source}
493 kind={sourceControlClients.get(props.source).icon}
503494 size={Platform.OS === "android" ? 24 : 18}
504495 colorClassName="accent-icon"
505496 />
......@@ -659,13 +650,13 @@ export function AddProjectSourceScreen() {
659650 key={candidate}
660651 source={candidate}
661652 selectedEnvironmentId={selectedEnvironment.environmentId}
662 ready={canCloneProject && readiness[candidate].ready}
653 ready={canCloneProject && readiness(candidate).ready}
663654 hint={
664655 !canCloneProject
665656 ? "This connection cannot clone projects."
666 : readiness[candidate].ready
657 : readiness(candidate).ready
667658 ? addProjectRemoteSourcePathHint(candidate)
668 : (readiness[candidate].hint ?? "")
659 : (readiness(candidate).hint ?? "")
669660 }
670661 isFirst={false}
671662 />
......@@ -813,7 +804,7 @@ export function AddProjectRepositoryScreen(props: {
813804 const result = await lookupRepositoryQuery({
814805 environmentId: environment.environmentId,
815806 input: {
816 provider: SourceControlProviderKind.make(provider),
807 provider,
817808 repository: repositoryInput.trim(),
818809 },
819810 });
......@@ -1153,7 +1144,7 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s
11531144 }
11541145 icon={
11551146 <SourceControlIcon
1156 kind="github"
1147 kind={sourceControlClients.get("github").icon}
11571148 size={Platform.OS === "android" ? 24 : 18}
11581149 colorClassName="accent-icon"
11591150 />
apps/mobile/src/state/thread-pr-presentation.ts+3-3
......@@ -4,7 +4,7 @@ import type {
44 ThreadPullRequestLink,
55 VcsStatusResult,
66} from "@t3tools/contracts";
7import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl";
7import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
88
99import {
1010 resolveThreadCurrentPullRequestLink,
......@@ -39,7 +39,7 @@ export function presentThreadPr(
3939 pr: ThreadPr,
4040 provider: VcsStatusResult["sourceControlProvider"] | null | undefined,
4141): ThreadPrPresentation {
42 const presentation = resolveChangeRequestPresentation(provider);
42 const { changeRequest } = sourceControlClients.get(provider?.kind);
4343 const isDraft = pr.state === "open" && pr.isDraft === true;
4444 return {
4545 kind: "pull-request",
......@@ -50,7 +50,7 @@ export function presentThreadPr(
5050 updatedAt: pr.updatedAt ?? null,
5151 url: pr.url,
5252 label: String(pr.number),
53 accessibilityLabel: `#${pr.number} ${presentation.longName} ${isDraft ? "draft" : pr.state}`,
53 accessibilityLabel: `#${pr.number} ${changeRequest.singular} ${isDraft ? "draft" : pr.state}`,
5454 textClassName: isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[pr.state],
5555 };
5656}
packages/client-runtime/src/operations/projects.test.ts+6-3
......@@ -219,9 +219,12 @@ describe("add project shared logic", () => {
219219 };
220220
221221 const readiness = buildAddProjectRemoteSourceReadiness(discovery);
222 expect(readiness.url.ready).toBe(true);
223 expect(readiness.github.ready).toBe(true);
224 expect(readiness.gitlab).toEqual({ ready: false, hint: "Run glab auth login" });
222 expect(readiness("url").ready).toBe(true);
223 expect(readiness(SourceControlProviderKind.make("github")).ready).toBe(true);
224 expect(readiness(SourceControlProviderKind.make("gitlab"))).toEqual({
225 ready: false,
226 hint: "Run glab auth login",
227 });
225228 expect(sortAddProjectProviderSources(readiness)[0]).toBe("github");
226229 });
227230
packages/client-runtime/src/operations/projects.ts+49-98
......@@ -24,14 +24,10 @@ import {
2424 resolveProjectPathForDispatch,
2525} from "../state/projects.ts";
2626import type { EnvironmentProject } from "../state/models.ts";
27import { sourceControlClients } from "../sourceControlClients.ts";
2728
28export type AddProjectRemoteProviderKind =
29 | "github"
30 | "gitlab"
31 | "forgejo"
32 | "bitbucket"
33 | "azure-devops";
34export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
29/** A host to clone from, or `url` for a pasted clone URL. */
30export type AddProjectRemoteSource = SourceControlProviderKind | "url";
3531
3632export function canCreateProjectInEnvironment(
3733 connectionPhase: EnvironmentConnectionPhase | null | undefined,
......@@ -52,10 +48,15 @@ export function availableScratchWorkspaceRoot(
5248 : null;
5349}
5450
55export type AddProjectRemoteSourceReadiness = Record<
56 AddProjectRemoteSource,
57 { readonly ready: boolean; readonly hint: string | null }
58>;
51export interface AddProjectRemoteSourceReadinessEntry {
52 readonly ready: boolean;
53 readonly hint: string | null;
54}
55
56/** Whether each clone source can be used on an environment, and why not. */
57export type AddProjectRemoteSourceReadiness = (
58 source: AddProjectRemoteSource,
59) => AddProjectRemoteSourceReadinessEntry;
5960
6061export type AddProjectCloneFlow =
6162 | {
......@@ -72,62 +73,31 @@ export type AddProjectCloneFlow =
7273 readonly remoteUrl: string;
7374 };
7475
75const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [
76 "url",
77 "github",
78 "gitlab",
79 "forgejo",
80 "bitbucket",
81 "azure-devops",
82];
83
84const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKind> = [
85 "github",
86 "gitlab",
87 "forgejo",
88 "bitbucket",
89 "azure-devops",
90];
76/** The hosts the clone picker offers, in the order the built-in definitions list them. */
77const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray<SourceControlProviderKind> =
78 sourceControlClients.definitions.map((definition) => definition.kind);
9179
9280export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): string {
93 switch (source) {
94 case "github":
95 return "GitHub";
96 case "forgejo":
97 return "Forgejo / Gitea";
98 case "gitlab":
99 return "GitLab";
100 case "bitbucket":
101 return "Bitbucket";
102 case "azure-devops":
103 return "Azure DevOps";
104 case "url":
105 return "Git URL";
106 }
81 return source === "url" ? "Git URL" : sourceControlClients.get(source).pickerLabel;
10782}
10883
10984export function addProjectRemoteSourcePathHint(source: AddProjectRemoteSource): string {
110 switch (source) {
111 case "forgejo":
112 case "github":
113 return "owner/repo";
114 case "gitlab":
115 return "group/project";
116 case "bitbucket":
117 return "workspace/repository";
118 case "azure-devops":
119 return "project/repository";
120 case "url":
121 return "URL";
122 }
85 return source === "url" ? "URL" : sourceControlClients.get(source).repositoryPathHint;
12386}
12487
12588export function addProjectRemoteSourceProvider(
12689 source: AddProjectRemoteSource,
127): AddProjectRemoteProviderKind | null {
90): SourceControlProviderKind | null {
12891 return source === "url" ? null : source;
12992}
13093
94/** A clone source named in a route or link, or `url` for anything this client does not ship. */
95export function parseAddProjectRemoteSource(
96 value: string | null | undefined,
97): AddProjectRemoteSource {
98 return (value ? sourceControlClients.find(value)?.kind : undefined) ?? "url";
99}
100
131101const GITHUB_REPOSITORY_SHORTHAND =
132102 /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]+(?:\.git)?$/;
133103
......@@ -139,18 +109,18 @@ export function normalizePastedCloneUrl(input: string): string {
139109 return `https://github.com/${repository}`;
140110}
141111
142/** GitHub and Forgejo default to HTTPS; other providers retain their existing SSH default. */
112/** The clone URL for the transport the repository's host defaults to. */
143113export function getDefaultCloneUrl(
144114 repository: Pick<SourceControlRepositoryInfo, "provider" | "url" | "sshUrl">,
145115): string {
146 return repository.provider === "github" || repository.provider === "forgejo"
116 return sourceControlClients.get(repository.provider).defaultCloneTransport === "https"
147117 ? repository.url
148118 : repository.sshUrl;
149119}
150120
151121export function sortAddProjectProviderSources(
152 readinessBySource: AddProjectRemoteSourceReadiness,
153): ReadonlyArray<AddProjectRemoteProviderKind> {
122 readiness: AddProjectRemoteSourceReadiness,
123): ReadonlyArray<SourceControlProviderKind> {
154124 return Arr.sort(
155125 ADD_PROJECT_REMOTE_PROVIDER_SOURCES,
156126 Order.mapInput(
......@@ -158,61 +128,41 @@ export function sortAddProjectProviderSources(
158128 ready: Order.flip(Order.Boolean),
159129 label: Order.String,
160130 }),
161 (source: AddProjectRemoteProviderKind) => ({
162 ready: readinessBySource[source].ready,
131 (source: SourceControlProviderKind) => ({
132 ready: readiness(source).ready,
163133 label: addProjectRemoteSourceLabel(source),
164134 }),
165135 ),
166136 );
167137}
168138
139const READY: AddProjectRemoteSourceReadinessEntry = { ready: true, hint: null };
140const UNAVAILABLE: AddProjectRemoteSourceReadinessEntry = {
141 ready: false,
142 hint: "Provider status unavailable. Open Source Control settings and rescan.",
143};
144
169145export function buildAddProjectRemoteSourceReadiness(
170146 discovery: SourceControlDiscoveryResult | null,
171147): AddProjectRemoteSourceReadiness {
172 const unavailable = {
173 ready: false,
174 hint: "Provider status unavailable. Open Source Control settings and rescan.",
175 } as const;
176 const readiness: AddProjectRemoteSourceReadiness = {
177 url: { ready: true, hint: null },
178 github: unavailable,
179 gitlab: unavailable,
180 forgejo: unavailable,
181 bitbucket: unavailable,
182 "azure-devops": unavailable,
183 };
184
185 if (!discovery) {
186 return readiness;
187 }
188
189148 const providerByKind = new Map(
190 discovery.sourceControlProviders.map((provider) => [provider.kind, provider]),
149 (discovery?.sourceControlProviders ?? []).map((provider) => [provider.kind, provider]),
191150 );
192 for (const source of ADD_PROJECT_REMOTE_SOURCES) {
193 const kind = addProjectRemoteSourceProvider(source);
194 if (!kind) continue;
195 const provider = providerByKind.get(SourceControlProviderKind.make(kind));
196 if (!provider) {
197 readiness[source] = unavailable;
198 continue;
199 }
200 if (provider.status !== "available") {
201 readiness[source] = { ready: false, hint: provider.installHint };
202 continue;
203 }
151 return (source) => {
152 if (source === "url") return READY;
153 const provider = providerByKind.get(source);
154 if (!provider) return UNAVAILABLE;
155 if (provider.status !== "available") return { ready: false, hint: provider.installHint };
204156 if (provider.auth.status === "unauthenticated") {
205 readiness[source] = {
157 return {
206158 ready: false,
207159 hint:
208160 Option.getOrNull(provider.auth.detail) ??
209161 `${provider.label} is not authenticated. Open Source Control settings for setup guidance.`,
210162 };
211 continue;
212163 }
213 readiness[source] = { ready: true, hint: null };
214 }
215 return readiness;
164 return READY;
165 };
216166}
217167
218168export function getAddProjectInitialQuery(baseDirectory: string | null | undefined): string {
......@@ -283,8 +233,9 @@ export function getNewProjectPathPreview(newProjectsRoot: string, name: string):
283233export function getNewProjectGitHubTarget(
284234 discovery: SourceControlDiscoveryResult | null,
285235): { readonly account: string | null } | null {
286 if (!buildAddProjectRemoteSourceReadiness(discovery).github.ready) return null;
287 const github = discovery?.sourceControlProviders.find((provider) => provider.kind === "github");
236 const kind = SourceControlProviderKind.make("github");
237 if (!buildAddProjectRemoteSourceReadiness(discovery)(kind).ready) return null;
238 const github = discovery?.sourceControlProviders.find((provider) => provider.kind === kind);
288239 return { account: github ? Option.getOrNull(github.auth.account) : null };
289240}
290241