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 }; }