diff --git a/apps/mobile/src/components/SourceControlIcon.tsx b/apps/mobile/src/components/SourceControlIcon.tsx
index 4c7fd9e05baa..0893dca83a12 100644
--- a/apps/mobile/src/components/SourceControlIcon.tsx
+++ b/apps/mobile/src/components/SourceControlIcon.tsx
@@ -1,12 +1,11 @@
-import Svg, { Circle, Defs, G, LinearGradient, Path, Stop } from "react-native-svg";
+import Svg, { Circle, Defs, G, LinearGradient, Path, Rect, Stop } from "react-native-svg";
import { withUniwind } from "uniwind";
const ThemedSvg = withUniwind(Svg);
-export type SourceControlIconKind = "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops";
-
+/** Draws a host's glyph by its client definition's `icon` key; null for a key it has no art for. */
export function SourceControlIcon(props: {
- readonly kind: SourceControlIconKind;
+ readonly kind: string;
readonly size?: number;
readonly color?: string;
readonly colorClassName?: string;
@@ -252,5 +251,14 @@ export function SourceControlIcon(props: {
/>
);
+ case "gitcafe":
+ // GitCafe's own mark from https://git.cafe/favicon.svg, inset like the web icon.
+ return (
+
+ );
+ default:
+ return null;
}
}
diff --git a/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx b/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx
index 17700e6599c9..8812c7347a5b 100644
--- a/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx
+++ b/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx
@@ -1,6 +1,9 @@
import type { StaticScreenProps } from "@react-navigation/native";
import { NativeStackScreenOptions } from "../../native/StackHeader";
-import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime/operations/projects";
+import {
+ addProjectRemoteSourceLabel,
+ parseAddProjectRemoteSource,
+} from "@t3tools/client-runtime/operations/projects";
import { AddProjectRepositoryScreen } from "./AddProjectScreen";
@@ -14,14 +17,7 @@ export function AddProjectRepositoryRoute({
}: StaticScreenProps) {
const params = route.params ?? {};
const source = Array.isArray(params.source) ? params.source[0] : params.source;
- const title =
- source === "github" ||
- source === "gitlab" ||
- source === "forgejo" ||
- source === "bitbucket" ||
- source === "azure-devops"
- ? addProjectRemoteSourceLabel(source)
- : "Git URL";
+ const title = addProjectRemoteSourceLabel(parseAddProjectRemoteSource(source));
return (
<>
diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx
index 2f58bae6c4d0..a72660f79603 100644
--- a/apps/mobile/src/features/projects/AddProjectScreen.tsx
+++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx
@@ -1,4 +1,3 @@
-import { SourceControlProviderKind } from "@t3tools/contracts";
import { MaterialListRow } from "../../components/MaterialListRow";
import { SettingsScreen } from "../settings/components/SettingsScreen";
import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
@@ -20,6 +19,7 @@ import {
getNewProjectGitHubTarget,
getNewProjectPathPreview,
normalizePastedCloneUrl,
+ parseAddProjectRemoteSource,
resolveAddProjectPath,
sortAddProjectProviderSources,
type AddProjectRemoteSource,
@@ -49,6 +49,7 @@ import {
type EnvironmentMachineKind,
ProjectId,
resolveEnvironmentMachineKind,
+ SourceControlProviderKind,
} from "@t3tools/contracts";
import { CommonActions, StackActions, useNavigation } from "@react-navigation/native";
import { SymbolView } from "../../components/AppSymbol";
@@ -71,6 +72,7 @@ import { AppText as Text, AppTextInput as TextInput } from "../../components/App
import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
import { ErrorBanner } from "../../components/ErrorBanner";
import { SourceControlIcon } from "../../components/SourceControlIcon";
+import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
import { ThemedSwitch } from "../../components/ThemedSwitch";
import { uuidv4 } from "../../lib/uuid";
import { useAtomCommand } from "../../state/use-atom-command";
@@ -123,18 +125,7 @@ function stringParam(value: string | string[] | undefined): string | null {
}
function sourceFromParam(value: string | string[] | undefined): AddProjectRemoteSource {
- const source = stringParam(value);
- if (
- source === "url" ||
- source === "github" ||
- source === "gitlab" ||
- source === "forgejo" ||
- source === "bitbucket" ||
- source === "azure-devops"
- ) {
- return source;
- }
- return "url";
+ return parseAddProjectRemoteSource(stringParam(value));
}
function SectionTitle(props: { readonly children: string }) {
@@ -499,7 +490,7 @@ function SourceControlRow(props: {
/>
) : (
@@ -659,13 +650,13 @@ export function AddProjectSourceScreen() {
key={candidate}
source={candidate}
selectedEnvironmentId={selectedEnvironment.environmentId}
- ready={canCloneProject && readiness[candidate].ready}
+ ready={canCloneProject && readiness(candidate).ready}
hint={
!canCloneProject
? "This connection cannot clone projects."
- : readiness[candidate].ready
+ : readiness(candidate).ready
? addProjectRemoteSourcePathHint(candidate)
- : (readiness[candidate].hint ?? "")
+ : (readiness(candidate).hint ?? "")
}
isFirst={false}
/>
@@ -813,7 +804,7 @@ export function AddProjectRepositoryScreen(props: {
const result = await lookupRepositoryQuery({
environmentId: environment.environmentId,
input: {
- provider: SourceControlProviderKind.make(provider),
+ provider,
repository: repositoryInput.trim(),
},
});
@@ -1153,7 +1144,7 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s
}
icon={
diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts
index 21234b7ff150..270e7d405c74 100644
--- a/apps/mobile/src/state/thread-pr-presentation.ts
+++ b/apps/mobile/src/state/thread-pr-presentation.ts
@@ -4,7 +4,7 @@ import type {
ThreadPullRequestLink,
VcsStatusResult,
} from "@t3tools/contracts";
-import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl";
+import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
import {
resolveThreadCurrentPullRequestLink,
@@ -39,7 +39,7 @@ export function presentThreadPr(
pr: ThreadPr,
provider: VcsStatusResult["sourceControlProvider"] | null | undefined,
): ThreadPrPresentation {
- const presentation = resolveChangeRequestPresentation(provider);
+ const { changeRequest } = sourceControlClients.get(provider?.kind);
const isDraft = pr.state === "open" && pr.isDraft === true;
return {
kind: "pull-request",
@@ -50,7 +50,7 @@ export function presentThreadPr(
updatedAt: pr.updatedAt ?? null,
url: pr.url,
label: String(pr.number),
- accessibilityLabel: `#${pr.number} ${presentation.longName} ${isDraft ? "draft" : pr.state}`,
+ accessibilityLabel: `#${pr.number} ${changeRequest.singular} ${isDraft ? "draft" : pr.state}`,
textClassName: isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[pr.state],
};
}
diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts
index 7df9904158b5..daf04d8b94e3 100644
--- a/packages/client-runtime/src/operations/projects.test.ts
+++ b/packages/client-runtime/src/operations/projects.test.ts
@@ -219,9 +219,12 @@ describe("add project shared logic", () => {
};
const readiness = buildAddProjectRemoteSourceReadiness(discovery);
- expect(readiness.url.ready).toBe(true);
- expect(readiness.github.ready).toBe(true);
- expect(readiness.gitlab).toEqual({ ready: false, hint: "Run glab auth login" });
+ expect(readiness("url").ready).toBe(true);
+ expect(readiness(SourceControlProviderKind.make("github")).ready).toBe(true);
+ expect(readiness(SourceControlProviderKind.make("gitlab"))).toEqual({
+ ready: false,
+ hint: "Run glab auth login",
+ });
expect(sortAddProjectProviderSources(readiness)[0]).toBe("github");
});
diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts
index f3b7bee7ef22..8f46b4426739 100644
--- a/packages/client-runtime/src/operations/projects.ts
+++ b/packages/client-runtime/src/operations/projects.ts
@@ -24,14 +24,10 @@ import {
resolveProjectPathForDispatch,
} from "../state/projects.ts";
import type { EnvironmentProject } from "../state/models.ts";
+import { sourceControlClients } from "../sourceControlClients.ts";
-export type AddProjectRemoteProviderKind =
- | "github"
- | "gitlab"
- | "forgejo"
- | "bitbucket"
- | "azure-devops";
-export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
+/** A host to clone from, or `url` for a pasted clone URL. */
+export type AddProjectRemoteSource = SourceControlProviderKind | "url";
export function canCreateProjectInEnvironment(
connectionPhase: EnvironmentConnectionPhase | null | undefined,
@@ -52,10 +48,15 @@ export function availableScratchWorkspaceRoot(
: null;
}
-export type AddProjectRemoteSourceReadiness = Record<
- AddProjectRemoteSource,
- { readonly ready: boolean; readonly hint: string | null }
->;
+export interface AddProjectRemoteSourceReadinessEntry {
+ readonly ready: boolean;
+ readonly hint: string | null;
+}
+
+/** Whether each clone source can be used on an environment, and why not. */
+export type AddProjectRemoteSourceReadiness = (
+ source: AddProjectRemoteSource,
+) => AddProjectRemoteSourceReadinessEntry;
export type AddProjectCloneFlow =
| {
@@ -72,62 +73,31 @@ export type AddProjectCloneFlow =
readonly remoteUrl: string;
};
-const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray = [
- "url",
- "github",
- "gitlab",
- "forgejo",
- "bitbucket",
- "azure-devops",
-];
-
-const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray = [
- "github",
- "gitlab",
- "forgejo",
- "bitbucket",
- "azure-devops",
-];
+/** The hosts the clone picker offers, in the order the built-in definitions list them. */
+const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray =
+ sourceControlClients.definitions.map((definition) => definition.kind);
export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): string {
- switch (source) {
- case "github":
- return "GitHub";
- case "forgejo":
- return "Forgejo / Gitea";
- case "gitlab":
- return "GitLab";
- case "bitbucket":
- return "Bitbucket";
- case "azure-devops":
- return "Azure DevOps";
- case "url":
- return "Git URL";
- }
+ return source === "url" ? "Git URL" : sourceControlClients.get(source).pickerLabel;
}
export function addProjectRemoteSourcePathHint(source: AddProjectRemoteSource): string {
- switch (source) {
- case "forgejo":
- case "github":
- return "owner/repo";
- case "gitlab":
- return "group/project";
- case "bitbucket":
- return "workspace/repository";
- case "azure-devops":
- return "project/repository";
- case "url":
- return "URL";
- }
+ return source === "url" ? "URL" : sourceControlClients.get(source).repositoryPathHint;
}
export function addProjectRemoteSourceProvider(
source: AddProjectRemoteSource,
-): AddProjectRemoteProviderKind | null {
+): SourceControlProviderKind | null {
return source === "url" ? null : source;
}
+/** A clone source named in a route or link, or `url` for anything this client does not ship. */
+export function parseAddProjectRemoteSource(
+ value: string | null | undefined,
+): AddProjectRemoteSource {
+ return (value ? sourceControlClients.find(value)?.kind : undefined) ?? "url";
+}
+
const GITHUB_REPOSITORY_SHORTHAND =
/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]+(?:\.git)?$/;
@@ -139,18 +109,18 @@ export function normalizePastedCloneUrl(input: string): string {
return `https://github.com/${repository}`;
}
-/** GitHub and Forgejo default to HTTPS; other providers retain their existing SSH default. */
+/** The clone URL for the transport the repository's host defaults to. */
export function getDefaultCloneUrl(
repository: Pick,
): string {
- return repository.provider === "github" || repository.provider === "forgejo"
+ return sourceControlClients.get(repository.provider).defaultCloneTransport === "https"
? repository.url
: repository.sshUrl;
}
export function sortAddProjectProviderSources(
- readinessBySource: AddProjectRemoteSourceReadiness,
-): ReadonlyArray {
+ readiness: AddProjectRemoteSourceReadiness,
+): ReadonlyArray {
return Arr.sort(
ADD_PROJECT_REMOTE_PROVIDER_SOURCES,
Order.mapInput(
@@ -158,61 +128,41 @@ export function sortAddProjectProviderSources(
ready: Order.flip(Order.Boolean),
label: Order.String,
}),
- (source: AddProjectRemoteProviderKind) => ({
- ready: readinessBySource[source].ready,
+ (source: SourceControlProviderKind) => ({
+ ready: readiness(source).ready,
label: addProjectRemoteSourceLabel(source),
}),
),
);
}
+const READY: AddProjectRemoteSourceReadinessEntry = { ready: true, hint: null };
+const UNAVAILABLE: AddProjectRemoteSourceReadinessEntry = {
+ ready: false,
+ hint: "Provider status unavailable. Open Source Control settings and rescan.",
+};
+
export function buildAddProjectRemoteSourceReadiness(
discovery: SourceControlDiscoveryResult | null,
): AddProjectRemoteSourceReadiness {
- const unavailable = {
- ready: false,
- hint: "Provider status unavailable. Open Source Control settings and rescan.",
- } as const;
- const readiness: AddProjectRemoteSourceReadiness = {
- url: { ready: true, hint: null },
- github: unavailable,
- gitlab: unavailable,
- forgejo: unavailable,
- bitbucket: unavailable,
- "azure-devops": unavailable,
- };
-
- if (!discovery) {
- return readiness;
- }
-
const providerByKind = new Map(
- discovery.sourceControlProviders.map((provider) => [provider.kind, provider]),
+ (discovery?.sourceControlProviders ?? []).map((provider) => [provider.kind, provider]),
);
- for (const source of ADD_PROJECT_REMOTE_SOURCES) {
- const kind = addProjectRemoteSourceProvider(source);
- if (!kind) continue;
- const provider = providerByKind.get(SourceControlProviderKind.make(kind));
- if (!provider) {
- readiness[source] = unavailable;
- continue;
- }
- if (provider.status !== "available") {
- readiness[source] = { ready: false, hint: provider.installHint };
- continue;
- }
+ return (source) => {
+ if (source === "url") return READY;
+ const provider = providerByKind.get(source);
+ if (!provider) return UNAVAILABLE;
+ if (provider.status !== "available") return { ready: false, hint: provider.installHint };
if (provider.auth.status === "unauthenticated") {
- readiness[source] = {
+ return {
ready: false,
hint:
Option.getOrNull(provider.auth.detail) ??
`${provider.label} is not authenticated. Open Source Control settings for setup guidance.`,
};
- continue;
}
- readiness[source] = { ready: true, hint: null };
- }
- return readiness;
+ return READY;
+ };
}
export function getAddProjectInitialQuery(baseDirectory: string | null | undefined): string {
@@ -283,8 +233,9 @@ export function getNewProjectPathPreview(newProjectsRoot: string, name: string):
export function getNewProjectGitHubTarget(
discovery: SourceControlDiscoveryResult | null,
): { readonly account: string | null } | null {
- if (!buildAddProjectRemoteSourceReadiness(discovery).github.ready) return null;
- const github = discovery?.sourceControlProviders.find((provider) => provider.kind === "github");
+ const kind = SourceControlProviderKind.make("github");
+ if (!buildAddProjectRemoteSourceReadiness(discovery)(kind).ready) return null;
+ const github = discovery?.sourceControlProviders.find((provider) => provider.kind === kind);
return { account: github ? Option.getOrNull(github.auth.account) : null };
}