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
log579159d6c6e96f9c18358e492fbf0b8af740e80c
tree7a1a8b34277431238c5a09ee1044d3f86e0010ff
parentc8a603307c2d50f8fb9d302f4b51f20fd9f91b33
signature Signed by PGP key B5690EEEBB952194

refactor(web): host presentation and behavior come from client definitions (#17757)

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

19 files changed, 228 insertions(+), 516 deletions(-)

apps/web/src/components/CommandPalette.tsx+42-178
......@@ -14,7 +14,15 @@ import {
1414 getNewProjectGitHubTarget,
1515 getNewProjectPathPreview,
1616 normalizePastedCloneUrl,
17 addProjectRemoteSourceLabel,
18 addProjectRemoteSourcePathHint,
19 addProjectRemoteSourceProvider,
20 buildAddProjectRemoteSourceReadiness,
21 sortAddProjectProviderSources,
22 type AddProjectRemoteSource,
23 type AddProjectRemoteSourceReadiness,
1724} from "@t3tools/client-runtime/operations/projects";
25import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
1826import { connectionStatusText } from "@t3tools/client-runtime/connection";
1927import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
2028import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
......@@ -187,14 +195,8 @@ import {
187195 CommandPaletteVirtualizedResults,
188196 scrollCommandPaletteRowIntoView,
189197} from "./CommandPaletteResults";
190import {
191 AzureDevOpsIcon,
192 BitbucketIcon,
193 GitCafeIcon,
194 GitHubIcon,
195 GitLabIcon,
196 ForgejoIcon,
197} from "./Icons";
198import { GitHubIcon } from "./Icons";
199import { sourceControlIcon } from "~/sourceControlPresentation";
198200import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
199201import { Checkbox } from "./ui/checkbox";
200202import { ProjectFavicon } from "./ProjectFavicon";
......@@ -257,15 +259,6 @@ interface AddProjectEnvironmentOption {
257259 readonly status: string;
258260}
259261
260type AddProjectRemoteProviderKind =
261 | "github"
262 | "gitlab"
263 | "forgejo"
264 | "bitbucket"
265 | "azure-devops"
266 | "gitcafe";
267type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
268
269262type AddProjectCloneFlow =
270263 | {
271264 readonly step: "repository";
......@@ -281,83 +274,10 @@ type AddProjectCloneFlow =
281274 readonly remoteUrl: string;
282275 };
283276
284const REMOTE_PROJECT_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [
285 "url",
286 "github",
287 "gitlab",
288 "forgejo",
289 "bitbucket",
290 "azure-devops",
291 "gitcafe",
292];
293const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKind> = [
294 "github",
295 "gitlab",
296 "forgejo",
297 "bitbucket",
298 "azure-devops",
299 "gitcafe",
300];
301
302function remoteProjectSourceLabel(source: AddProjectRemoteSource): string {
303 switch (source) {
304 case "github":
305 return "GitHub";
306 case "forgejo":
307 return "Forgejo / Gitea";
308 case "gitlab":
309 return "GitLab";
310 case "bitbucket":
311 return "Bitbucket";
312 case "azure-devops":
313 return "Azure DevOps";
314 case "gitcafe":
315 return "GitCafe";
316 case "url":
317 return "Git URL";
318 }
319}
320
321function remoteProjectSourcePathHint(source: AddProjectRemoteSource): string {
322 switch (source) {
323 case "forgejo":
324 case "github":
325 case "gitcafe":
326 return "owner/repo";
327 case "gitlab":
328 return "group/project";
329 case "bitbucket":
330 return "workspace/repository";
331 case "azure-devops":
332 return "project/repository";
333 case "url":
334 return "URL";
335 }
336}
337
338function remoteProjectSourceProvider(
339 source: AddProjectRemoteSource,
340): AddProjectRemoteProviderKind | null {
341 return source === "url" ? null : source;
342}
343
344277function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: string): ReactNode {
345 switch (source) {
346 case "github":
347 return <GitHubIcon className={className} />;
348 case "forgejo":
349 return <ForgejoIcon className={className} />;
350 case "gitlab":
351 return <GitLabIcon className={className} />;
352 case "bitbucket":
353 return <BitbucketIcon className={className} />;
354 case "azure-devops":
355 return <AzureDevOpsIcon className={className} />;
356 case "gitcafe":
357 return <GitCafeIcon className={className} />;
358 case "url":
359 return <LinkIcon className={className} />;
360 }
278 if (source === "url") return <LinkIcon className={className} />;
279 const Icon = sourceControlIcon(sourceControlClients.get(source));
280 return <Icon className={className} />;
361281}
362282
363283function projectFaviconIcon(project: Project): ReactNode {
......@@ -370,82 +290,26 @@ function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string
370290 if (flow.source === "url") {
371291 return "Enter Git clone URL";
372292 }
373 return `Enter ${remoteProjectSourceLabel(flow.source)} repository (${remoteProjectSourcePathHint(flow.source)})`;
374}
375
376function sourceProviderKind(source: AddProjectRemoteSource): AddProjectRemoteProviderKind | null {
377 return source === "url" ? null : source;
378}
379
380function sortAddProjectProviderSources(
381 readinessBySource: AddProjectRemoteSourceReadiness,
382): ReadonlyArray<AddProjectRemoteProviderKind> {
383 return REMOTE_PROJECT_PROVIDER_SOURCES.toSorted((left, right) => {
384 const leftReady = readinessBySource[left].ready;
385 const rightReady = readinessBySource[right].ready;
386 if (leftReady !== rightReady) {
387 return leftReady ? -1 : 1;
388 }
389 return remoteProjectSourceLabel(left).localeCompare(remoteProjectSourceLabel(right));
390 });
293 return `Enter ${addProjectRemoteSourceLabel(flow.source)} repository (${addProjectRemoteSourcePathHint(flow.source)})`;
391294}
392295
393type AddProjectRemoteSourceReadiness = Record<
394 AddProjectRemoteSource,
395 { readonly ready: boolean; readonly hint: string | null }
396>;
397
398function buildAddProjectRemoteSourceReadiness(
296/** The palette's readiness, pointing at Settings by its menu path. */
297function buildPaletteRemoteSourceReadiness(
399298 discovery: SourceControlDiscoveryResult | null,
400299): AddProjectRemoteSourceReadiness {
401 const unavailable = {
402 ready: false,
403 hint: "Provider status unavailable. Open Settings -> Source Control and rescan.",
404 } as const;
405 const defaultReadiness: AddProjectRemoteSourceReadiness = {
406 url: { ready: true, hint: null },
407 github: unavailable,
408 gitlab: unavailable,
409 forgejo: unavailable,
410 bitbucket: unavailable,
411 "azure-devops": unavailable,
412 gitcafe: unavailable,
300 const readiness = buildAddProjectRemoteSourceReadiness(discovery);
301 return (source) => {
302 const entry = readiness(source);
303 return entry.hint === null
304 ? entry
305 : {
306 ...entry,
307 hint: entry.hint.replace(
308 "Open Source Control settings",
309 "Open Settings -> Source Control",
310 ),
311 };
413312 };
414
415 if (!discovery) {
416 return defaultReadiness;
417 }
418
419 const providerByKind = new Map(
420 discovery.sourceControlProviders.map((provider) => [provider.kind, provider]),
421 );
422 const readiness = { ...defaultReadiness };
423
424 for (const source of REMOTE_PROJECT_SOURCES) {
425 const kind = sourceProviderKind(source);
426 if (!kind) continue;
427 const provider = providerByKind.get(SourceControlProviderKind.make(kind));
428 if (!provider) {
429 readiness[source] = unavailable;
430 continue;
431 }
432 if (provider.status !== "available") {
433 readiness[source] = { ready: false, hint: provider.installHint };
434 continue;
435 }
436 if (provider.auth.status === "unauthenticated") {
437 readiness[source] = {
438 ready: false,
439 hint:
440 Option.getOrNull(provider.auth.detail) ??
441 `${provider.label} is not authenticated. Open Settings -> Source Control for setup guidance.`,
442 };
443 continue;
444 }
445 readiness[source] = { ready: true, hint: null };
446 }
447
448 return readiness;
449313}
450314
451315function errorMessage(error: unknown): string {
......@@ -1673,13 +1537,13 @@ function OpenCommandPaletteDialog(props: {
16731537 ];
16741538
16751539 for (const source of orderedSources) {
1676 const label = remoteProjectSourceLabel(source);
1540 const label = addProjectRemoteSourceLabel(source);
16771541 const title = source === "url" ? "Git URL" : `${label} repository`;
16781542 const description =
16791543 source === "url"
16801544 ? "Clone from a remote URL"
1681 : `Clone ${label} ${remoteProjectSourcePathHint(source)}`;
1682 const readiness = readinessBySource[source];
1545 : `Clone ${label} ${addProjectRemoteSourcePathHint(source)}`;
1546 const readiness = readinessBySource(source);
16831547 const disabledHint = readiness.hint;
16841548
16851549 const titleTrailingContent = readiness.ready ? undefined : (
......@@ -1767,7 +1631,7 @@ function OpenCommandPaletteDialog(props: {
17671631 addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />,
17681632 groups: buildAddProjectSourceGroups(
17691633 environmentId,
1770 buildAddProjectRemoteSourceReadiness(
1634 buildPaletteRemoteSourceReadiness(
17711635 browseEnvironmentId === environmentId ? sourceControlDiscovery.data : null,
17721636 ),
17731637 ),
......@@ -2105,13 +1969,13 @@ function OpenCommandPaletteDialog(props: {
21051969 "repository",
21061970 "repo",
21071971 "git",
2108 "github",
2109 "gitlab",
2110 "forgejo",
2111 "bitbucket",
2112 "azure",
2113 "devops",
2114 "gitcafe",
1972 // Every host's name, spelled as a word: "azure devops" searches as `azure` and `devops`.
1973 ...sourceControlClients.definitions.flatMap((definition) =>
1974 definition.pickerLabel
1975 .toLowerCase()
1976 .split(/[^a-z0-9]+/u)
1977 .filter(Boolean),
1978 ),
21151979 "url",
21161980 "environment",
21171981 ],
......@@ -2356,7 +2220,7 @@ function OpenCommandPaletteDialog(props: {
23562220 currentView.groups[0]?.value === sourceSelectionViewValue
23572221 ? buildAddProjectSourceGroups(
23582222 addProjectEnvironmentId,
2359 buildAddProjectRemoteSourceReadiness(sourceControlDiscovery.data),
2223 buildPaletteRemoteSourceReadiness(sourceControlDiscovery.data),
23602224 )
23612225 : currentView?.groups[0]?.value === "themes"
23622226 ? changeThemeItem.groups
......@@ -2606,7 +2470,7 @@ function OpenCommandPaletteDialog(props: {
26062470 return;
26072471 }
26082472
2609 const provider = remoteProjectSourceProvider(addProjectCloneFlow.source);
2473 const provider = addProjectRemoteSourceProvider(addProjectCloneFlow.source);
26102474 if (!provider) {
26112475 const destinationPath = getCloneDestinationPath(
26122476 getDefaultCloneParentPath(addProjectCloneFlow.environmentId),
......@@ -2631,7 +2495,7 @@ function OpenCommandPaletteDialog(props: {
26312495 const lookupResult = await lookupRepository({
26322496 environmentId: addProjectCloneFlow.environmentId,
26332497 input: {
2634 provider: SourceControlProviderKind.make(provider),
2498 provider,
26352499 repository: rawRepository,
26362500 },
26372501 });
apps/web/src/components/GitActionsControl.logic.ts+5-10
......@@ -4,11 +4,8 @@ import type {
44 VcsStatusResult,
55} from "@t3tools/contracts";
66import { isTemporaryWorktreeBranch } from "@t3tools/shared/git";
7import {
8 DEFAULT_CHANGE_REQUEST_TERMINOLOGY,
9 getChangeRequestTerminology,
10 type ChangeRequestTerminology,
11} from "../sourceControlPresentation";
7import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
8import type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients";
129
1310export type GitActionIconName = "commit" | "push" | "pr";
1411
......@@ -68,9 +65,7 @@ export function resolveGitActionResultToastTiming(
6865function resolveChangeRequestTerminology(
6966 gitStatus: VcsStatusResult | null,
7067): ChangeRequestTerminology {
71 return gitStatus?.sourceControlProvider
72 ? getChangeRequestTerminology(gitStatus.sourceControlProvider)
73 : DEFAULT_CHANGE_REQUEST_TERMINOLOGY;
68 return sourceControlClients.get(gitStatus?.sourceControlProvider?.kind).changeRequest;
7469}
7570
7671export function resolveGitActionProgressPresentation(input: {
......@@ -129,7 +124,7 @@ export function buildGitActionProgressStages(input: {
129124 shouldPushBeforePr?: boolean;
130125 terminology?: ChangeRequestTerminology;
131126}): string[] {
132 const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY;
127 const terminology = input.terminology ?? sourceControlClients.get(undefined).changeRequest;
133128 const branchStages = input.featureBranch ? ["Preparing feature ref..."] : [];
134129 const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing...";
135130 const prStages = [
......@@ -406,7 +401,7 @@ export function resolveDefaultBranchActionDialogCopy(input: {
406401}): DefaultBranchActionDialogCopy {
407402 const branchLabel = input.branchName;
408403 const suffix = ` on "${branchLabel}". You can continue on this ref or create a feature ref and run the same action there.`;
409 const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY;
404 const terminology = input.terminology ?? sourceControlClients.get(undefined).changeRequest;
410405
411406 if (input.action === "push" || input.action === "commit_push") {
412407 if (input.includesCommit) {
apps/web/src/components/GitActionsControl.tsx+44-122
......@@ -44,14 +44,6 @@ import {
4444 GlobeIcon,
4545} from "lucide-react";
4646import { Radio as RadioPrimitive } from "@base-ui/react/radio";
47import {
48 AzureDevOpsIcon,
49 BitbucketIcon,
50 GitHubIcon,
51 GitLabIcon,
52 ForgejoIcon,
53 GitCafeIcon,
54} from "~/components/Icons";
5547import { RadioGroup } from "~/components/ui/radio-group";
5648import { Spinner } from "~/components/ui/spinner";
5749import { Toggle, ToggleGroup } from "~/components/ui/toggle-group";
......@@ -131,7 +123,8 @@ import {
131123 THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,
132124 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,
133125} from "./chat/threadDetailsPanelStyles";
134import { getSourceControlPresentation } from "~/sourceControlPresentation";
126import { getSourceControlPresentation, sourceControlIcon } from "~/sourceControlPresentation";
127import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
135128import { useOpenLink } from "~/browser/useOpenLink";
136129
137130interface GitActionsControlProps {
......@@ -158,14 +151,6 @@ interface PendingDefaultBranchAction {
158151 filePaths?: string[];
159152}
160153
161type PublishProviderKind =
162 | "github"
163 | "gitlab"
164 | "forgejo"
165 | "bitbucket"
166 | "azure-devops"
167 | "gitcafe";
168
169154type GitActionToastId = ReturnType<typeof toastManager.add>;
170155
171156interface RunGitActionWithToastInput {
......@@ -203,77 +188,25 @@ function requestVcsStatusRefresh(
203188}
204189const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const;
205190
206const PUBLISH_PROVIDER_OPTIONS = [
207 {
208 value: "forgejo",
209 label: "Forgejo / Gitea",
210 description: "Your signed-in server",
211 host: "your server",
212 pathPlaceholder: "owner/repo",
213 Icon: ForgejoIcon,
214 },
215 {
216 value: "github",
217 label: "GitHub",
218 description: "github.com",
219 host: "github.com",
220 pathPlaceholder: "owner/repo",
221 Icon: GitHubIcon,
222 },
223 {
224 value: "gitlab",
225 label: "GitLab",
226 description: "gitlab.com",
227 host: "gitlab.com",
228 pathPlaceholder: "group/project",
229 Icon: GitLabIcon,
230 },
231 {
232 value: "bitbucket",
233 label: "Bitbucket",
234 description: "bitbucket.org",
235 host: "bitbucket.org",
236 pathPlaceholder: "workspace/repository",
237 Icon: BitbucketIcon,
238 },
239 {
240 value: "azure-devops",
241 label: "Azure DevOps",
242 description: "dev.azure.com",
243 host: "dev.azure.com",
244 pathPlaceholder: "project/repository",
245 Icon: AzureDevOpsIcon,
246 },
247 {
248 value: "gitcafe",
249 label: "GitCafe",
250 description: "git.cafe",
251 host: "git.cafe",
252 pathPlaceholder: "owner/repo",
253 Icon: GitCafeIcon,
254 },
255] as const satisfies ReadonlyArray<{
256 readonly value: PublishProviderKind;
257 readonly label: string;
258 readonly description: string;
259 readonly host: string;
260 readonly pathPlaceholder: string;
261 readonly Icon: typeof GitHubIcon;
262}>;
263
264function publishProviderOption(provider: PublishProviderKind) {
191/** Every built-in host, in the order the built-in definitions list them. */
192const PUBLISH_PROVIDER_OPTIONS = sourceControlClients.definitions.map((definition) => ({
193 value: definition.kind,
194 label: definition.pickerLabel,
195 description: definition.publishDescription,
196 pathPlaceholder: definition.repositoryPathHint,
197 definition,
198 Icon: sourceControlIcon(definition),
199}));
200
201function publishProviderOption(provider: SourceControlProviderKind) {
265202 return (
266203 PUBLISH_PROVIDER_OPTIONS.find((option) => option.value === provider) ??
267 PUBLISH_PROVIDER_OPTIONS[0]
204 PUBLISH_PROVIDER_OPTIONS[0]!
268205 );
269206}
270207
271function isPublishProviderKind(provider: string): provider is PublishProviderKind {
272 return PUBLISH_PROVIDER_OPTIONS.some((option) => option.value === provider);
273}
274
275208function getPublishProviderReadiness(input: {
276 provider: PublishProviderKind;
209 provider: SourceControlProviderKind;
277210 sourceControlProviders: ReadonlyArray<SourceControlProviderDiscoveryItem>;
278211}): { readonly ready: boolean; readonly hint: string | null } {
279212 const discovered = input.sourceControlProviders.find(
......@@ -553,7 +486,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
553486 }),
554487 );
555488 const [selectedPublishProvider, setSelectedPublishProvider] =
556 useState<PublishProviderKind | null>(null);
489 useState<SourceControlProviderKind | null>(null);
557490 const [publishRepositoryOverride, setPublishRepositoryOverride] = useState<string | null>(null);
558491 const [publishVisibility, setPublishVisibility] =
559492 useState<SourceControlRepositoryVisibility>("private");
......@@ -574,43 +507,32 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
574507 );
575508 const publishRepositoryAction = useSourceControlPublishRepositoryAction(sourceControlScope);
576509 const publishAccountByProvider = useMemo(() => {
577 const accounts: Record<PublishProviderKind, string | null> = {
578 github: null,
579 gitlab: null,
580 forgejo: null,
581 bitbucket: null,
582 "azure-devops": null,
583 gitcafe: null,
584 };
510 const accounts = new Map<string, string | null>();
585511 for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) {
586 const kind: string = provider.kind;
587 if (isPublishProviderKind(kind)) {
588 accounts[kind] = Option.getOrNull(provider.auth.account);
589 }
512 accounts.set(provider.kind, Option.getOrNull(provider.auth.account));
590513 }
591514 return accounts;
592515 }, [sourceControlDiscovery.data]);
593516 const publishProviderReadiness = useMemo(() => {
594517 const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? [];
595 return Object.fromEntries(
518 const readiness = new Map(
596519 PUBLISH_PROVIDER_OPTIONS.map((option) => [
597520 option.value,
598 getPublishProviderReadiness({
599 provider: option.value,
600 sourceControlProviders,
601 }),
521 getPublishProviderReadiness({ provider: option.value, sourceControlProviders }),
602522 ]),
603 ) as Record<PublishProviderKind, { readonly ready: boolean; readonly hint: string | null }>;
523 );
524 return (provider: SourceControlProviderKind) =>
525 readiness.get(provider) ?? { ready: false, hint: null };
604526 }, [sourceControlDiscovery.data]);
605527 const hasReadyPublishProvider = useMemo(
606 () => PUBLISH_PROVIDER_OPTIONS.some((option) => publishProviderReadiness[option.value].ready),
528 () => PUBLISH_PROVIDER_OPTIONS.some((option) => publishProviderReadiness(option.value).ready),
607529 [publishProviderReadiness],
608530 );
609531 const sortedPublishProviderOptions = useMemo(
610532 () =>
611533 PUBLISH_PROVIDER_OPTIONS.toSorted((left, right) => {
612 const leftReady = publishProviderReadiness[left.value].ready;
613 const rightReady = publishProviderReadiness[right.value].ready;
534 const leftReady = publishProviderReadiness(left.value).ready;
535 const rightReady = publishProviderReadiness(right.value).ready;
614536 if (leftReady !== rightReady) {
615537 return leftReady ? -1 : 1;
616538 }
......@@ -619,26 +541,26 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
619541 [publishProviderReadiness],
620542 );
621543 const firstReadyPublishProvider = sortedPublishProviderOptions.find(
622 (option) => publishProviderReadiness[option.value].ready,
544 (option) => publishProviderReadiness(option.value).ready,
623545 )?.value;
624546 const publishProvider =
625 selectedPublishProvider !== null && publishProviderReadiness[selectedPublishProvider].ready
547 selectedPublishProvider !== null && publishProviderReadiness(selectedPublishProvider).ready
626548 ? selectedPublishProvider
627 : (firstReadyPublishProvider ?? selectedPublishProvider ?? "github");
628 const selectedPublishProviderReadiness = publishProviderReadiness[publishProvider];
629 const publishRepositoryPrefill = publishAccountByProvider[publishProvider]
630 ? `${publishAccountByProvider[publishProvider]}/`
631 : "";
549 : (firstReadyPublishProvider ??
550 selectedPublishProvider ??
551 sourceControlClients.get(undefined).kind);
552 const selectedPublishProviderReadiness = publishProviderReadiness(publishProvider);
553 const publishAccount = publishAccountByProvider.get(publishProvider) ?? null;
554 const publishRepositoryPrefill = publishAccount ? `${publishAccount}/` : "";
632555 const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill;
633556 const currentPublishProvider = publishProviderOption(publishProvider);
634 const publishHost =
635 publishProvider === "forgejo"
636 ? (Option.getOrNull(
637 sourceControlDiscovery.data?.sourceControlProviders.find(
638 (provider) => provider.kind === "forgejo",
639 )?.auth.host ?? Option.none(),
640 ) ?? currentPublishProvider.host)
641 : currentPublishProvider.host;
557 const publishHost = currentPublishProvider.definition.publishHost(
558 Option.getOrNull(
559 sourceControlDiscovery.data?.sourceControlProviders.find(
560 (provider) => provider.kind === publishProvider,
561 )?.auth.host ?? Option.none(),
562 ),
563 );
642564 const publishPathPlaceholder = currentPublishProvider.pathPlaceholder;
643565 const publishProviderLabel = currentPublishProvider.label;
644566 const publishWizardSteps = ["Provider", "Repository", "Summary"] as const;
......@@ -672,7 +594,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
672594
673595 void (async () => {
674596 const result = await publishRepositoryAction.run({
675 provider: SourceControlProviderKind.make(publishProvider),
597 provider: publishProvider,
676598 repository: publishRepository.trim(),
677599 visibility: publishVisibility,
678600 remoteName: publishRemoteName.trim() || "origin",
......@@ -757,14 +679,14 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
757679 <RadioGroup
758680 value={publishProvider}
759681 onValueChange={(value) => {
760 setSelectedPublishProvider(value as PublishProviderKind);
682 setSelectedPublishProvider(value as SourceControlProviderKind);
761683 setPublishRepositoryOverride(null);
762684 }}
763685 aria-labelledby="publish-provider-cards-label"
764686 className="grid grid-cols-2"
765687 >
766688 {sortedPublishProviderOptions.map((option) => {
767 const readiness = publishProviderReadiness[option.value];
689 const readiness = publishProviderReadiness(option.value);
768690 const isSelected = publishProvider === option.value && readiness.ready;
769691 if (!readiness.ready) {
770692 return (
apps/web/src/components/ThreadStatusIndicators.tsx+9-20
......@@ -6,14 +6,12 @@ import {
66import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
77
88import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests";
9import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
109import {
1110 resolveEnvironmentMachineKind,
1211 type EnvironmentId,
1312 type ThreadLinkedPullRequest,
1413 type ThreadPullRequestLink,
1514 type VcsStatusResult,
16 SourceControlProviderKind,
1715} from "@t3tools/contracts";
1816import { Atom } from "effect/reactivity";
1917import { FolderGit2Icon, TerminalIcon } from "lucide-react";
......@@ -38,7 +36,7 @@ import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../sta
3836import { useThreadRunningTerminalIds } from "../state/terminalSessions";
3937import { vcsEnvironment } from "../state/vcs";
4038import { useUiStateStore } from "../uiStateStore";
41import { resolveChangeRequestPresentation } from "../sourceControlPresentation";
39import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
4240import {
4341 resolveThreadLastVisitedAt,
4442 resolveThreadStatusPill,
......@@ -131,19 +129,10 @@ export function linkedPullRequestSnapshotStatus(
131129): LinkedThreadPullRequestStatus | null {
132130 const snapshot = link.snapshot;
133131 if (snapshot === null) return null;
134 // GitCafe's `/pulls/` routes look like Forgejo's, so its two hosts are told apart first.
135 const kind =
136 detectSourceControlProviderFromRemoteUrl(link.url)?.kind === "gitcafe"
137 ? "gitcafe"
138 : link.url.includes("/-/merge_requests/")
139 ? "gitlab"
140 : link.url.includes("/pullrequest/")
141 ? "azure-devops"
142 : link.url.includes("/pull-requests/")
143 ? "bitbucket"
144 : link.url.includes("/pulls/")
145 ? "forgejo"
146 : "github";
132 // A link records no host kind, so read it from the URL's change request path.
133 const kind = (
134 sourceControlClients.findByChangeRequestUrl(link.url) ?? sourceControlClients.get(undefined)
135 ).kind;
147136 return {
148137 pr: {
149138 number: link.number,
......@@ -155,7 +144,7 @@ export function linkedPullRequestSnapshotStatus(
155144 baseRef: snapshot.baseBranch,
156145 ...(snapshot.updatedAt === null ? {} : { updatedAt: snapshot.updatedAt }),
157146 },
158 sourceControlProvider: { kind: SourceControlProviderKind.make(kind), name: kind, baseUrl: "" },
147 sourceControlProvider: { kind, name: kind, baseUrl: "" },
159148 };
160149}
161150
......@@ -442,12 +431,12 @@ export function prStatusIndicator(
442431 provider: VcsStatusResult["sourceControlProvider"] | null | undefined,
443432): PrStatusIndicator | null {
444433 if (!pr) return null;
445 const presentation = resolveChangeRequestPresentation(provider);
434 const { shortLabel } = sourceControlClients.get(provider?.kind).changeRequest;
446435 const state = resolvePullRequestState({ state: pr.state, isDraft: pr.isDraft === true });
447436
448 const tooltipLead = `${presentation.shortName} #${pr.number} - ${state.label}`;
437 const tooltipLead = `${shortLabel} #${pr.number} - ${state.label}`;
449438 return {
450 label: `${presentation.shortName} ${state.label.toLowerCase()}`,
439 label: `${shortLabel} ${state.label.toLowerCase()}`,
451440 colorClass: state.toneClassName,
452441 Icon: state.Icon,
453442 tooltip: `${tooltipLead}: ${pr.title}`,
apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts+6-1
......@@ -1,6 +1,11 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { changeRequestWebUrl, resolveLinkPullRequestInput } from "./LinkPullRequestDialog";
3import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
4
5import { resolveLinkPullRequestInput } from "./LinkPullRequestDialog";
6
7const changeRequestWebUrl = (kind: string, host: string, repository: string, number: number) =>
8 sourceControlClients.get(kind).changeRequestUrl({ host, repository, number });
49
510const project = {
611 host: "github.com",
apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx+11-12
......@@ -1,10 +1,5 @@
1import { changeRequestUrlFor as changeRequestWebUrl } from "@t3tools/shared/changeRequestUrl";
2export { changeRequestUrlFor as changeRequestWebUrl } from "@t3tools/shared/changeRequestUrl";
3import {
4 pullRequestHostOf,
5 type ScopedThreadRef,
6 type SourceControlProviderKind,
7} from "@t3tools/contracts";
1import { pullRequestHostOf, type ScopedThreadRef } from "@t3tools/contracts";
2import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
83import { useAtomValue } from "@effect/atom-react";
94import { useCallback, useEffect, useMemo, useRef, useState } from "react";
105
......@@ -138,15 +133,19 @@ function LinkPullRequestDialog({
138133 identity.displayName ??
139134 (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null);
140135 if (repository === null) return null;
141 const kind = identity.provider as SourceControlProviderKind;
142 const host = pullRequestHostOf(identity, kind);
136 const definition = sourceControlClients.get(identity.provider ?? "unknown");
137 const host = pullRequestHostOf(identity, definition.kind);
143138 return {
144139 host,
145140 repository,
146141 webUrl: (number: number) =>
147 kind === "forgejo" && identity.webUrl
148 ? `${identity.webUrl.replace(/\/+$/, "")}/pulls/${number}`
149 : changeRequestWebUrl(kind, host, repository, number, identity.locator.remoteUrl),
142 definition.changeRequestUrl({
143 host,
144 repository,
145 number,
146 remoteUrl: identity.locator.remoteUrl,
147 webUrl: identity.webUrl,
148 }),
150149 };
151150 }, [environmentProjects, projectId]);
152151 const linking = usePullRequestLinking(threadRef.environmentId);
apps/web/src/components/pullRequest/PullRequestComposer.tsx+4-1
......@@ -1,3 +1,4 @@
1import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
12/**
23 * The single floating control over a pull request. Commenting on the change and submitting the
34 * review that carries the Code tab's line comments used to float as two buttons that crowded
......@@ -158,7 +159,9 @@ export function PullRequestComposer({
158159 environmentId={environmentId}
159160 reference={reference}
160161 verdicts={verdicts}
161 requestChangesSummaryRequired={detail.provider === "forgejo"}
162 requestChangesSummaryRequired={sourceControlClients
163 .get(detail.provider)
164 .reviewSummaryRequired("request-changes")}
162165 textareaRef={reviewRef}
163166 pending={reviewPending}
164167 onPendingChange={setReviewPending}
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+15-11
......@@ -83,6 +83,7 @@ import {
8383} from "~/state/pullRequests";
8484import { useAtomCommand } from "~/state/use-atom-command";
8585import { PullRequestStackMenu } from "./PullRequestStackMenu";
86import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
8687import { PullRequestThreadLinks } from "./PullRequestThreadLinks";
8788import { vcsEnvironment } from "~/state/vcs";
8889import { formatRelativeTimeLabel } from "~/timestampFormat";
......@@ -302,7 +303,7 @@ const openNumberContextMenu = (
302303 event.stopPropagation();
303304 void showPullRequestLinkContextMenu({
304305 url: detail.url,
305 openLabel: openOnHostLabel(detail.provider),
306 openLabel: openOnHostLabel(sourceControlClients.get(detail.provider)),
306307 position: { x: event.clientX, y: event.clientY },
307308 });
308309};
......@@ -732,16 +733,18 @@ export function PullRequestDetailPanel({
732733 if (detail?.autoMergeMethod !== undefined) setMergeMethod(detail.autoMergeMethod);
733734 }, [detail?.autoMergeMethod, pullRequestKey]);
734735 const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url);
736 const hostClient = sourceControlClients.get(detail?.provider);
735737 const markdownContext = useMemo(
736 () => ({ repositoryUrl: detail?.provider === "github" ? repositoryUrl : null, threadRef }),
737 [detail?.provider, repositoryUrl, threadRef],
738 () => ({
739 repositoryUrl:
740 repositoryUrl === null ? null : hostClient.referenceAutolinkRepositoryUrl(repositoryUrl),
741 threadRef,
742 }),
743 [hostClient, repositoryUrl, threadRef],
738744 );
739745 const authorProfileUrl =
740 detail?.provider === "github" &&
741 detail.author !== null &&
742 !detail.author.login.endsWith("[bot]") &&
743 repositoryUrl !== null
744 ? new URL(`/${encodeURIComponent(detail.author.login)}`, repositoryUrl).toString()
746 detail?.author && repositoryUrl !== null
747 ? hostClient.authorProfileUrl(detail.author.login, repositoryUrl)
745748 : null;
746749 const checkoutCommand = handoffSummary
747750 ? pullRequestCheckoutCommand(
......@@ -1725,7 +1728,7 @@ export function PullRequestDetailPanel({
17251728 </button>
17261729 }
17271730 />
1728 <TooltipPopup side="top">{openOnHostLabel(detail.provider)}</TooltipPopup>
1731 <TooltipPopup side="top">{openOnHostLabel(hostClient)}</TooltipPopup>
17291732 </Tooltip>
17301733 </>
17311734 ) : null}
......@@ -1780,7 +1783,7 @@ export function PullRequestDetailPanel({
17801783 </button>
17811784 }
17821785 />
1783 <TooltipPopup side="top">{openOnHostLabel(detail.provider)}</TooltipPopup>
1786 <TooltipPopup side="top">{openOnHostLabel(hostClient)}</TooltipPopup>
17841787 </Tooltip>
17851788 <Tooltip>
17861789 <TooltipTrigger
......@@ -1806,6 +1809,7 @@ export function PullRequestDetailPanel({
18061809 ) : null}
18071810 {nativeStack ? (
18081811 <PullRequestStackMenu
1812 hostLabel={hostClient.label}
18091813 stack={nativeStack}
18101814 notice={nativeStackQuery.notice}
18111815 onRetry={nativeStackQuery.error ? nativeStackQuery.refresh : undefined}
......@@ -2211,7 +2215,7 @@ export function PullRequestDetailPanel({
22112215 ) : null}
22122216 <MenuItem onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}>
22132217 <ArrowUpRightIcon className="size-3.5" />
2214 {openOnHostLabel(detail.provider)}
2218 {openOnHostLabel(hostClient)}
22152219 </MenuItem>
22162220 <MenuItem onClick={() => copyReference(detail.url, "PR link")}>
22172221 <LinkIcon className="size-3.5" />
apps/web/src/components/pullRequest/PullRequestRow.tsx+2-1
......@@ -1,3 +1,4 @@
1import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
12import { SearchIcon } from "lucide-react";
23import { PullRequestStackPopover } from "./PullRequestStackPopover";
34import {
......@@ -158,7 +159,7 @@ function PullRequestRowImpl({
158159 event.stopPropagation();
159160 void showPullRequestLinkContextMenu({
160161 url: entry.url,
161 openLabel: openOnHostLabel(entry.provider),
162 openLabel: openOnHostLabel(sourceControlClients.get(entry.provider)),
162163 position: { x: event.clientX, y: event.clientY },
163164 });
164165 }}
apps/web/src/components/pullRequest/PullRequestStackMenu.tsx+5-2
......@@ -28,6 +28,7 @@ import { PullRequestStackLayerContent } from "./PullRequestStackLayerContent";
2828import { PullRequestGlyph } from "./pullRequestIcons";
2929
3030export function PullRequestStackMenu({
31 hostLabel,
3132 stack,
3233 reference,
3334 environmentId,
......@@ -39,6 +40,8 @@ export function PullRequestStackMenu({
3940 notice,
4041 onRetry,
4142}: {
43 /** The host's name, which reports what it did with the stack. */
44 hostLabel: string;
4245 notice?: string | null;
4346 onRetry?: (() => void) | undefined;
4447 stack: PullRequestStack;
......@@ -117,7 +120,7 @@ export function PullRequestStackMenu({
117120 title: action === "merge" ? "Stack merge request completed" : "Stack rebased",
118121 description:
119122 action === "merge"
120 ? "GitHub merged the stack or added it to its merge queue."
123 ? `${hostLabel} merged the stack or added it to its merge queue.`
121124 : undefined,
122125 });
123126 }
......@@ -232,7 +235,7 @@ export function PullRequestStackMenu({
232235 </DialogTitle>
233236 <DialogDescription>
234237 {confirmation === "merge"
235 ? `Merge #${reference.number} and its unmerged layers below into ${stack.base} using ${mergeMethod}. GitHub checks their rules before merging or queueing them and rebases the remaining stack after merging.`
238 ? `Merge #${reference.number} and its unmerged layers below into ${stack.base} using ${mergeMethod}. ${hostLabel} checks their rules before merging or queueing them and rebases the remaining stack after merging.`
236239 : `Rebase the remote branches from bottom to top onto ${stack.base}. This rewrites branch history and may restart checks. If a layer fails, earlier updates remain.`}
237240 </DialogDescription>
238241 </DialogHeader>
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+4-4
......@@ -1,3 +1,4 @@
1import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
12import { useAtomCommand } from "~/state/use-atom-command";
23import type {
34 EnvironmentId,
......@@ -83,10 +84,9 @@ function CommentIdentity({
8384 detail: PullRequestDetailView;
8485}) {
8586 const actor = comment.author;
86 const profileUrl =
87 detail.provider === "github" && actor && !actor.login.endsWith("[bot]")
88 ? new URL(`/${encodeURIComponent(actor.login)}`, detail.url).toString()
89 : null;
87 const profileUrl = actor
88 ? sourceControlClients.get(detail.provider).authorProfileUrl(actor.login, detail.url)
89 : null;
9090 return (
9191 <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1 text-xs">
9292 <PullRequestActorLabel actor={actor} profileUrl={profileUrl} className="max-w-full" />
apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx+2-1
......@@ -1,5 +1,6 @@
11import type { ProjectId, ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts";
22import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
3import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
34import {
45 resolveThreadPullRequestChains,
56 visibleThreadPullRequests,
......@@ -210,7 +211,7 @@ function LinkRow({
210211 </TooltipTrigger>
211212 <TooltipPopup>
212213 {stack.kind === "native"
213 ? `GitHub stack of ${stack.size}: merging a layer lands the ones below it.`
214 ? `${sourceControlClients.hostLabelForChangeRequestUrl(link.url)} stack of ${stack.size}: merging a layer lands the ones below it.`
214215 : `${stack.size} pull requests chained by base branch.`}
215216 </TooltipPopup>
216217 </Tooltip>
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+22-37
......@@ -1,3 +1,4 @@
1import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
12import * as Schema from "effect/Schema";
23
34import {
......@@ -68,9 +69,6 @@ export function resolvePullRequestMergeMethod(
6869 return allowed[0] ?? "merge";
6970}
7071
71const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/;
72const bitbucketRepositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
73
7472export type PullRequestPrimaryControl =
7573 | "resolve"
7674 | "ready"
......@@ -117,32 +115,9 @@ export function pullRequestCheckoutCommand(
117115 headRepositoryNameWithOwner?: string | null,
118116 repositoryUrl?: string | null,
119117): string | null {
120 switch (provider) {
121 case "github":
122 return `gh pr checkout ${number}`;
123 case "gitlab":
124 return `glab mr checkout ${number}`;
125 case "forgejo":
126 return repositoryUrl
127 ? `git fetch '${repositoryUrl.replaceAll("'", "'\\''")}' refs/pull/${number}/head && git checkout -B pulls/${number} FETCH_HEAD`
128 : null;
129 case "azure-devops":
130 return `az repos pr checkout --id ${number}`;
131 case "gitcafe":
132 return `cafe pr checkout ${number}`;
133 case "bitbucket": {
134 if (
135 !headRepositoryNameWithOwner ||
136 !bitbucketRepositoryName.test(headRepositoryNameWithOwner) ||
137 !safeShellArgument.test(headBranch)
138 ) {
139 return null;
140 }
141 return `git clone --single-branch --branch ${headBranch} https://bitbucket.org/${headRepositoryNameWithOwner}.git t3code-pr-${number}`;
142 }
143 default:
144 return null;
145 }
118 return sourceControlClients
119 .get(provider)
120 .checkoutCommand({ number, headBranch, headRepositoryNameWithOwner, repositoryUrl });
146121}
147122
148123/** Build a checkout command from identity metadata while the detail request is still pending. */
......@@ -151,15 +126,23 @@ export function loadingPullRequestCheckoutCommand(
151126 identity: RepositoryIdentity | null | undefined,
152127): string | null {
153128 const host = reference.host?.trim().toLowerCase();
154 const provider =
155 identity?.provider ??
156 (host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : null);
157 if (provider !== "github" && provider !== "gitlab" && provider !== "azure-devops") return null;
158 const kind = SourceControlProviderKind.make(provider);
159 if (identity?.provider !== undefined && host && pullRequestHostOf(identity, kind) !== host) {
129 // Before the detail loads, only the number is known, so only hosts whose command needs nothing
130 // else qualify. Without an identity, only a public hostname names the host.
131 const definition = identity?.provider
132 ? sourceControlClients.find(identity.provider)
133 : host
134 ? sourceControlClients.findByPublicHost(host)
135 : undefined;
136 const command = definition?.checkoutCommand({ number: reference.number, headBranch: "" });
137 if (!definition || !command) return null;
138 if (
139 identity?.provider !== undefined &&
140 host &&
141 pullRequestHostOf(identity, definition.kind) !== host
142 ) {
160143 return null;
161144 }
162 return pullRequestCheckoutCommand(kind, reference.number, "");
145 return command;
163146}
164147
165148/** Activity changes only when the same host resource reports a newer revision. */
......@@ -967,7 +950,9 @@ export function buildFixFindingsHandoff(input: {
967950 ? ["Failing checks:", ...includedChecks.map((check) => `> ${check}`)]
968951 : []),
969952 ...(input.commentsTruncated
970 ? ["The conversation was truncated; more review comments may exist on GitHub."]
953 ? [
954 `The conversation was truncated; more review comments may exist on ${sourceControlClients.hostLabelForChangeRequestUrl(input.url)}.`,
955 ]
971956 : []),
972957 ...(omitted > 0 ? [`${omitted} further findings were omitted.`] : []),
973958 ...(includedThreads.length === 0 &&
apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts+6-11
......@@ -1,4 +1,8 @@
11import type { ContextMenuItem } from "@t3tools/contracts";
2import {
3 type SourceControlClientDefinition,
4 UNKNOWN_SOURCE_CONTROL_CLIENT,
5} from "@t3tools/client-runtime/source-control-clients";
26
37import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
48import { readLocalApi } from "~/localApi";
......@@ -8,17 +12,8 @@ import { toastManager } from "../ui/toast";
812export type PullRequestLinkContextMenuAction = "copy-link" | "open-external";
913
1014/** Named for the host rather than "externally": the point is where you will land. */
11const OPEN_ON_HOST_LABELS: Partial<Record<string, string>> = {
12 github: "Open on GitHub",
13 gitlab: "Open on GitLab",
14 forgejo: "Open on Forgejo",
15 bitbucket: "Open on Bitbucket",
16 gitcafe: "Open on GitCafe",
17 "azure-devops": "Open on Azure DevOps",
18};
19
20export const openOnHostLabel = (provider: string): string =>
21 OPEN_ON_HOST_LABELS[provider] ?? "Open on host";
15export const openOnHostLabel = (host: SourceControlClientDefinition): string =>
16 host === UNKNOWN_SOURCE_CONTROL_CLIENT ? "Open on host" : `Open on ${host.label}`;
2217
2318/** Copy first: it is the reason to right-click a number rather than click it. */
2419function pullRequestLinkContextMenuItems(
apps/web/src/components/settings/SourceControlSettings.tsx+5-22
......@@ -5,7 +5,6 @@ import * as Option from "effect/Option";
55import { useEffect, useState, type ReactNode } from "react";
66import type {
77 BackgroundActivitySettings,
8 SourceControlProviderKind,
98 SourceControlDiscoveryResult,
109 SourceControlProviderAuth,
1110 SourceControlProviderDiscoveryItem,
......@@ -46,17 +45,7 @@ import {
4645import { Switch } from "../ui/switch";
4746import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
4847
49import {
50 AzureDevOpsIcon,
51 BitbucketIcon,
52 GitCafeIcon,
53 GitHubIcon,
54 GitIcon,
55 GitLabIcon,
56 ForgejoIcon,
57 JujutsuIcon,
58 type Icon,
59} from "../Icons";
48import { GitIcon, JujutsuIcon, type Icon } from "../Icons";
6049import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings";
6150import { GitHubAccountSettings } from "./GitHubAccountSettings";
6251import { GitHubTokenSettings } from "./GitHubTokenSettings";
......@@ -72,21 +61,14 @@ import {
7261} from "./settingsLayout";
7362import { searchableSetting } from "./settingsSearch";
7463import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
64import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
65import { sourceControlIcon } from "~/sourceControlPresentation";
7566
7667const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
7768 versionControlSystems: [],
7869 sourceControlProviders: [],
7970};
8071
81const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<string, Icon>> = {
82 github: GitHubIcon,
83 gitlab: GitLabIcon,
84 forgejo: ForgejoIcon,
85 "azure-devops": AzureDevOpsIcon,
86 bitbucket: BitbucketIcon,
87 gitcafe: GitCafeIcon,
88};
89
9072const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = {
9173 git: GitIcon,
9274 jj: JujutsuIcon,
......@@ -185,8 +167,9 @@ function SourceControlItemMark({
185167 readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem;
186168}) {
187169 const dotClassName = itemStatusDot(item);
170 const host = isProviderDiscoveryItem(item) ? sourceControlClients.find(item.kind) : undefined;
188171 const Icon = isProviderDiscoveryItem(item)
189 ? SOURCE_CONTROL_PROVIDER_ICONS[item.kind]
172 ? host && sourceControlIcon(host)
190173 : VCS_ICONS[item.kind];
191174
192175 if (!Icon) {
apps/web/src/sourceControlPresentation.ts+44-63
......@@ -1,17 +1,10 @@
11import type { ElementType } from "react";
2import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts";
3export {
4 DEFAULT_CHANGE_REQUEST_TERMINOLOGY,
5 getChangeRequestTerminology,
6 resolveChangeRequestPresentation,
7 type ChangeRequestPresentation,
8 type ChangeRequestTerminology,
9} from "@t3tools/shared/sourceControl";
10import {
11 getChangeRequestTerminology,
12 resolveChangeRequestPresentation,
13 type ChangeRequestTerminology,
14} from "@t3tools/shared/sourceControl";
2import type { SourceControlProviderInfo } from "@t3tools/contracts";
3import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
4import type {
5 ChangeRequestTerminology,
6 SourceControlClientDefinition,
7} from "@t3tools/client-runtime/source-control-clients";
158import {
169 AzureDevOpsIcon,
1710 BitbucketIcon,
......@@ -22,65 +15,53 @@ import {
2215} from "./components/Icons";
2316import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
2417
18export type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients";
19
20type SourceControlIcon = ElementType<{ className?: string }>;
21
22/** Web art for each definition `icon` key; a key without art draws the change request glyph. */
23const SOURCE_CONTROL_ICONS: Partial<Record<string, SourceControlIcon>> = {
24 github: GitHubIcon,
25 gitlab: GitLabIcon,
26 forgejo: ForgejoIcon,
27 bitbucket: BitbucketIcon,
28 "azure-devops": AzureDevOpsIcon,
29 gitcafe: GitCafeIcon,
30};
31
32export function sourceControlIcon(definition: SourceControlClientDefinition): SourceControlIcon {
33 return SOURCE_CONTROL_ICONS[definition.icon] ?? PullRequestGlyph.pullRequest;
34}
35
2536export interface SourceControlPresentation {
37 readonly definition: SourceControlClientDefinition;
2638 readonly providerName: string;
2739 readonly terminology: ChangeRequestTerminology;
28 readonly Icon: ElementType<{ className?: string }>;
40 readonly Icon: SourceControlIcon;
2941}
3042
43function presentDefinition(
44 definition: SourceControlClientDefinition,
45 providerName: string,
46): SourceControlPresentation {
47 return {
48 definition,
49 providerName,
50 terminology: definition.changeRequest,
51 Icon: sourceControlIcon(definition),
52 };
53}
54
55/** Named as the server reported the host, e.g. "GitHub Self-Hosted", when it reported one. */
3156export function getSourceControlPresentation(
3257 provider: SourceControlProviderInfo | null | undefined,
3358): SourceControlPresentation {
34 const presentation = resolveChangeRequestPresentation(provider);
35 switch (presentation.icon) {
36 case "github":
37 return {
38 providerName: provider?.name || presentation.providerName,
39 terminology: getChangeRequestTerminology(provider),
40 Icon: GitHubIcon,
41 };
42 case "forgejo":
43 return {
44 providerName: provider?.name || presentation.providerName,
45 terminology: getChangeRequestTerminology(provider),
46 Icon: ForgejoIcon,
47 };
48 case "gitlab":
49 return {
50 providerName: provider?.name || presentation.providerName,
51 terminology: getChangeRequestTerminology(provider),
52 Icon: GitLabIcon,
53 };
54 case "azure-devops":
55 return {
56 providerName: provider?.name || presentation.providerName,
57 terminology: getChangeRequestTerminology(provider),
58 Icon: AzureDevOpsIcon,
59 };
60 case "bitbucket":
61 return {
62 providerName: provider?.name || presentation.providerName,
63 terminology: getChangeRequestTerminology(provider),
64 Icon: BitbucketIcon,
65 };
66 case "gitcafe":
67 return {
68 providerName: provider?.name || presentation.providerName,
69 terminology: getChangeRequestTerminology(provider),
70 Icon: GitCafeIcon,
71 };
72 case "change-request":
73 return {
74 providerName: provider?.name || presentation.providerName,
75 terminology: getChangeRequestTerminology(provider),
76 Icon: PullRequestGlyph.pullRequest,
77 };
78 }
59 const definition = sourceControlClients.get(provider?.kind);
60 return presentDefinition(definition, provider?.name || definition.label);
7961}
8062
8163/** For surfaces that know only the host kind, such as a change request row or filter. */
82export function getSourceControlPresentationForKind(
83 kind: SourceControlProviderKind,
84): SourceControlPresentation {
85 return getSourceControlPresentation({ kind, name: "", baseUrl: "" });
64export function getSourceControlPresentationForKind(kind: string): SourceControlPresentation {
65 const definition = sourceControlClients.get(kind);
66 return presentDefinition(definition, definition.label);
8667}
packages/client-runtime/src/sourceControlClients.ts+1
......@@ -12,6 +12,7 @@ import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition"
1212import { makeSourceControlClientRegistry } from "@t3tools/source-control-core/client/definition";
1313
1414export {
15 UNKNOWN_SOURCE_CONTROL_CLIENT,
1516 type ChangeRequestTerminology,
1617 type SourceControlClientDefinition,
1718} from "@t3tools/source-control-core/client/definition";
packages/shared/src/sourceControl.ts-19
......@@ -24,11 +24,6 @@ export interface ChangeRequestTerminology {
2424 readonly singular: string;
2525}
2626
27export const DEFAULT_CHANGE_REQUEST_TERMINOLOGY: ChangeRequestTerminology = {
28 shortLabel: "PR",
29 singular: "pull request",
30};
31
3227const GITHUB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
3328 icon: "github",
3429 providerName: "GitHub",
......@@ -132,20 +127,6 @@ function resolveChangeRequestPresentationForKind(
132127 return resolveChangeRequestPresentation({ kind, name: "", baseUrl: "" });
133128}
134129
135export function getChangeRequestTerminology(
136 provider: SourceControlProviderInfo | null | undefined,
137): ChangeRequestTerminology {
138 if (!provider) {
139 return DEFAULT_CHANGE_REQUEST_TERMINOLOGY;
140 }
141
142 const presentation = resolveChangeRequestPresentation(provider);
143 return {
144 shortLabel: presentation.shortName,
145 singular: presentation.longName,
146 };
147}
148
149130export function getChangeRequestTerminologyForKind(
150131 kind: SourceControlProviderKind,
151132): ChangeRequestTerminology {
packages/source-control-core/src/client/definition.ts+1-1
......@@ -101,7 +101,7 @@ export function defineSourceControlClient<const Definition extends SourceControl
101101}
102102
103103/** What clients show for a host they ship no definition for, including `unknown`. */
104const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = {
104export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = {
105105 kind: SourceControlProviderKind.make("unknown"),
106106 label: "source control",
107107 pickerLabel: "source control",