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 {...@@ -14,7 +14,15 @@ import {
14 getNewProjectGitHubTarget,14 getNewProjectGitHubTarget,
15 getNewProjectPathPreview,15 getNewProjectPathPreview,
16 normalizePastedCloneUrl,16 normalizePastedCloneUrl,
17 addProjectRemoteSourceLabel,
18 addProjectRemoteSourcePathHint,
19 addProjectRemoteSourceProvider,
20 buildAddProjectRemoteSourceReadiness,
21 sortAddProjectProviderSources,
22 type AddProjectRemoteSource,
23 type AddProjectRemoteSourceReadiness,
17} from "@t3tools/client-runtime/operations/projects";24} from "@t3tools/client-runtime/operations/projects";
25import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
18import { connectionStatusText } from "@t3tools/client-runtime/connection";26import { connectionStatusText } from "@t3tools/client-runtime/connection";
19import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";27import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
20import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";28import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
...@@ -187,14 +195,8 @@ import {...@@ -187,14 +195,8 @@ import {
187 CommandPaletteVirtualizedResults,195 CommandPaletteVirtualizedResults,
188 scrollCommandPaletteRowIntoView,196 scrollCommandPaletteRowIntoView,
189} from "./CommandPaletteResults";197} from "./CommandPaletteResults";
190import {198import { GitHubIcon } from "./Icons";
191 AzureDevOpsIcon,199import { sourceControlIcon } from "~/sourceControlPresentation";
192 BitbucketIcon,
193 GitCafeIcon,
194 GitHubIcon,
195 GitLabIcon,
196 ForgejoIcon,
197} from "./Icons";
198import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";200import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
199import { Checkbox } from "./ui/checkbox";201import { Checkbox } from "./ui/checkbox";
200import { ProjectFavicon } from "./ProjectFavicon";202import { ProjectFavicon } from "./ProjectFavicon";
...@@ -257,15 +259,6 @@ interface AddProjectEnvironmentOption {...@@ -257,15 +259,6 @@ interface AddProjectEnvironmentOption {
257 readonly status: string;259 readonly status: string;
258}260}
259261
260type AddProjectRemoteProviderKind =
261 | "github"
262 | "gitlab"
263 | "forgejo"
264 | "bitbucket"
265 | "azure-devops"
266 | "gitcafe";
267type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
268
269type AddProjectCloneFlow =262type AddProjectCloneFlow =
270 | {263 | {
271 readonly step: "repository";264 readonly step: "repository";
...@@ -281,83 +274,10 @@ type AddProjectCloneFlow =...@@ -281,83 +274,10 @@ type AddProjectCloneFlow =
281 readonly remoteUrl: string;274 readonly remoteUrl: string;
282 };275 };
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
344function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: string): ReactNode {277function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: string): ReactNode {
345 switch (source) {278 if (source === "url") return <LinkIcon className={className} />;
346 case "github":279 const Icon = sourceControlIcon(sourceControlClients.get(source));
347 return <GitHubIcon className={className} />;280 return <Icon 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 }
361}281}
362282
363function projectFaviconIcon(project: Project): ReactNode {283function projectFaviconIcon(project: Project): ReactNode {
...@@ -370,82 +290,26 @@ function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string...@@ -370,82 +290,26 @@ function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string
370 if (flow.source === "url") {290 if (flow.source === "url") {
371 return "Enter Git clone URL";291 return "Enter Git clone URL";
372 }292 }
373 return `Enter ${remoteProjectSourceLabel(flow.source)} repository (${remoteProjectSourcePathHint(flow.source)})`;293 return `Enter ${addProjectRemoteSourceLabel(flow.source)} repository (${addProjectRemoteSourcePathHint(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 });
391}294}
392295
393type AddProjectRemoteSourceReadiness = Record<296/** The palette's readiness, pointing at Settings by its menu path. */
394 AddProjectRemoteSource,297function buildPaletteRemoteSourceReadiness(
395 { readonly ready: boolean; readonly hint: string | null }
396>;
397
398function buildAddProjectRemoteSourceReadiness(
399 discovery: SourceControlDiscoveryResult | null,298 discovery: SourceControlDiscoveryResult | null,
400): AddProjectRemoteSourceReadiness {299): AddProjectRemoteSourceReadiness {
401 const unavailable = {300 const readiness = buildAddProjectRemoteSourceReadiness(discovery);
402 ready: false,301 return (source) => {
403 hint: "Provider status unavailable. Open Settings -> Source Control and rescan.",302 const entry = readiness(source);
404 } as const;303 return entry.hint === null
405 const defaultReadiness: AddProjectRemoteSourceReadiness = {304 ? entry
406 url: { ready: true, hint: null },305 : {
407 github: unavailable,306 ...entry,
408 gitlab: unavailable,307 hint: entry.hint.replace(
409 forgejo: unavailable,308 "Open Source Control settings",
410 bitbucket: unavailable,309 "Open Settings -> Source Control",
411 "azure-devops": unavailable,310 ),
412 gitcafe: unavailable,311 };
413 };312 };
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;
449}313}
450314
451function errorMessage(error: unknown): string {315function errorMessage(error: unknown): string {
...@@ -1673,13 +1537,13 @@ function OpenCommandPaletteDialog(props: {...@@ -1673,13 +1537,13 @@ function OpenCommandPaletteDialog(props: {
1673 ];1537 ];
16741538
1675 for (const source of orderedSources) {1539 for (const source of orderedSources) {
1676 const label = remoteProjectSourceLabel(source);1540 const label = addProjectRemoteSourceLabel(source);
1677 const title = source === "url" ? "Git URL" : `${label} repository`;1541 const title = source === "url" ? "Git URL" : `${label} repository`;
1678 const description =1542 const description =
1679 source === "url"1543 source === "url"
1680 ? "Clone from a remote URL"1544 ? "Clone from a remote URL"
1681 : `Clone ${label} ${remoteProjectSourcePathHint(source)}`;1545 : `Clone ${label} ${addProjectRemoteSourcePathHint(source)}`;
1682 const readiness = readinessBySource[source];1546 const readiness = readinessBySource(source);
1683 const disabledHint = readiness.hint;1547 const disabledHint = readiness.hint;
16841548
1685 const titleTrailingContent = readiness.ready ? undefined : (1549 const titleTrailingContent = readiness.ready ? undefined : (
...@@ -1767,7 +1631,7 @@ function OpenCommandPaletteDialog(props: {...@@ -1767,7 +1631,7 @@ function OpenCommandPaletteDialog(props: {
1767 addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />,1631 addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />,
1768 groups: buildAddProjectSourceGroups(1632 groups: buildAddProjectSourceGroups(
1769 environmentId,1633 environmentId,
1770 buildAddProjectRemoteSourceReadiness(1634 buildPaletteRemoteSourceReadiness(
1771 browseEnvironmentId === environmentId ? sourceControlDiscovery.data : null,1635 browseEnvironmentId === environmentId ? sourceControlDiscovery.data : null,
1772 ),1636 ),
1773 ),1637 ),
...@@ -2105,13 +1969,13 @@ function OpenCommandPaletteDialog(props: {...@@ -2105,13 +1969,13 @@ function OpenCommandPaletteDialog(props: {
2105 "repository",1969 "repository",
2106 "repo",1970 "repo",
2107 "git",1971 "git",
2108 "github",1972 // Every host's name, spelled as a word: "azure devops" searches as `azure` and `devops`.
2109 "gitlab",1973 ...sourceControlClients.definitions.flatMap((definition) =>
2110 "forgejo",1974 definition.pickerLabel
2111 "bitbucket",1975 .toLowerCase()
2112 "azure",1976 .split(/[^a-z0-9]+/u)
2113 "devops",1977 .filter(Boolean),
2114 "gitcafe",1978 ),
2115 "url",1979 "url",
2116 "environment",1980 "environment",
2117 ],1981 ],
...@@ -2356,7 +2220,7 @@ function OpenCommandPaletteDialog(props: {...@@ -2356,7 +2220,7 @@ function OpenCommandPaletteDialog(props: {
2356 currentView.groups[0]?.value === sourceSelectionViewValue2220 currentView.groups[0]?.value === sourceSelectionViewValue
2357 ? buildAddProjectSourceGroups(2221 ? buildAddProjectSourceGroups(
2358 addProjectEnvironmentId,2222 addProjectEnvironmentId,
2359 buildAddProjectRemoteSourceReadiness(sourceControlDiscovery.data),2223 buildPaletteRemoteSourceReadiness(sourceControlDiscovery.data),
2360 )2224 )
2361 : currentView?.groups[0]?.value === "themes"2225 : currentView?.groups[0]?.value === "themes"
2362 ? changeThemeItem.groups2226 ? changeThemeItem.groups
...@@ -2606,7 +2470,7 @@ function OpenCommandPaletteDialog(props: {...@@ -2606,7 +2470,7 @@ function OpenCommandPaletteDialog(props: {
2606 return;2470 return;
2607 }2471 }
26082472
2609 const provider = remoteProjectSourceProvider(addProjectCloneFlow.source);2473 const provider = addProjectRemoteSourceProvider(addProjectCloneFlow.source);
2610 if (!provider) {2474 if (!provider) {
2611 const destinationPath = getCloneDestinationPath(2475 const destinationPath = getCloneDestinationPath(
2612 getDefaultCloneParentPath(addProjectCloneFlow.environmentId),2476 getDefaultCloneParentPath(addProjectCloneFlow.environmentId),
...@@ -2631,7 +2495,7 @@ function OpenCommandPaletteDialog(props: {...@@ -2631,7 +2495,7 @@ function OpenCommandPaletteDialog(props: {
2631 const lookupResult = await lookupRepository({2495 const lookupResult = await lookupRepository({
2632 environmentId: addProjectCloneFlow.environmentId,2496 environmentId: addProjectCloneFlow.environmentId,
2633 input: {2497 input: {
2634 provider: SourceControlProviderKind.make(provider),2498 provider,
2635 repository: rawRepository,2499 repository: rawRepository,
2636 },2500 },
2637 });2501 });
apps/web/src/components/GitActionsControl.logic.ts+5-10
...@@ -4,11 +4,8 @@ import type {...@@ -4,11 +4,8 @@ import type {
4 VcsStatusResult,4 VcsStatusResult,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { isTemporaryWorktreeBranch } from "@t3tools/shared/git";6import { isTemporaryWorktreeBranch } from "@t3tools/shared/git";
7import {7import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
8 DEFAULT_CHANGE_REQUEST_TERMINOLOGY,8import type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients";
9 getChangeRequestTerminology,
10 type ChangeRequestTerminology,
11} from "../sourceControlPresentation";
129
13export type GitActionIconName = "commit" | "push" | "pr";10export type GitActionIconName = "commit" | "push" | "pr";
1411
...@@ -68,9 +65,7 @@ export function resolveGitActionResultToastTiming(...@@ -68,9 +65,7 @@ export function resolveGitActionResultToastTiming(
68function resolveChangeRequestTerminology(65function resolveChangeRequestTerminology(
69 gitStatus: VcsStatusResult | null,66 gitStatus: VcsStatusResult | null,
70): ChangeRequestTerminology {67): ChangeRequestTerminology {
71 return gitStatus?.sourceControlProvider68 return sourceControlClients.get(gitStatus?.sourceControlProvider?.kind).changeRequest;
72 ? getChangeRequestTerminology(gitStatus.sourceControlProvider)
73 : DEFAULT_CHANGE_REQUEST_TERMINOLOGY;
74}69}
7570
76export function resolveGitActionProgressPresentation(input: {71export function resolveGitActionProgressPresentation(input: {
...@@ -129,7 +124,7 @@ export function buildGitActionProgressStages(input: {...@@ -129,7 +124,7 @@ export function buildGitActionProgressStages(input: {
129 shouldPushBeforePr?: boolean;124 shouldPushBeforePr?: boolean;
130 terminology?: ChangeRequestTerminology;125 terminology?: ChangeRequestTerminology;
131}): string[] {126}): string[] {
132 const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY;127 const terminology = input.terminology ?? sourceControlClients.get(undefined).changeRequest;
133 const branchStages = input.featureBranch ? ["Preparing feature ref..."] : [];128 const branchStages = input.featureBranch ? ["Preparing feature ref..."] : [];
134 const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing...";129 const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing...";
135 const prStages = [130 const prStages = [
...@@ -406,7 +401,7 @@ export function resolveDefaultBranchActionDialogCopy(input: {...@@ -406,7 +401,7 @@ export function resolveDefaultBranchActionDialogCopy(input: {
406}): DefaultBranchActionDialogCopy {401}): DefaultBranchActionDialogCopy {
407 const branchLabel = input.branchName;402 const branchLabel = input.branchName;
408 const suffix = ` on "${branchLabel}". You can continue on this ref or create a feature ref and run the same action there.`;403 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
411 if (input.action === "push" || input.action === "commit_push") {406 if (input.action === "push" || input.action === "commit_push") {
412 if (input.includesCommit) {407 if (input.includesCommit) {
apps/web/src/components/GitActionsControl.tsx+44-122
...@@ -44,14 +44,6 @@ import {...@@ -44,14 +44,6 @@ import {
44 GlobeIcon,44 GlobeIcon,
45} from "lucide-react";45} from "lucide-react";
46import { Radio as RadioPrimitive } from "@base-ui/react/radio";46import { 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";
55import { RadioGroup } from "~/components/ui/radio-group";47import { RadioGroup } from "~/components/ui/radio-group";
56import { Spinner } from "~/components/ui/spinner";48import { Spinner } from "~/components/ui/spinner";
57import { Toggle, ToggleGroup } from "~/components/ui/toggle-group";49import { Toggle, ToggleGroup } from "~/components/ui/toggle-group";
...@@ -131,7 +123,8 @@ import {...@@ -131,7 +123,8 @@ import {
131 THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,123 THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,
132 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,124 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,
133} from "./chat/threadDetailsPanelStyles";125} from "./chat/threadDetailsPanelStyles";
134import { getSourceControlPresentation } from "~/sourceControlPresentation";126import { getSourceControlPresentation, sourceControlIcon } from "~/sourceControlPresentation";
127import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
135import { useOpenLink } from "~/browser/useOpenLink";128import { useOpenLink } from "~/browser/useOpenLink";
136129
137interface GitActionsControlProps {130interface GitActionsControlProps {
...@@ -158,14 +151,6 @@ interface PendingDefaultBranchAction {...@@ -158,14 +151,6 @@ interface PendingDefaultBranchAction {
158 filePaths?: string[];151 filePaths?: string[];
159}152}
160153
161type PublishProviderKind =
162 | "github"
163 | "gitlab"
164 | "forgejo"
165 | "bitbucket"
166 | "azure-devops"
167 | "gitcafe";
168
169type GitActionToastId = ReturnType<typeof toastManager.add>;154type GitActionToastId = ReturnType<typeof toastManager.add>;
170155
171interface RunGitActionWithToastInput {156interface RunGitActionWithToastInput {
...@@ -203,77 +188,25 @@ function requestVcsStatusRefresh(...@@ -203,77 +188,25 @@ function requestVcsStatusRefresh(
203}188}
204const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const;189const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const;
205190
206const PUBLISH_PROVIDER_OPTIONS = [191/** Every built-in host, in the order the built-in definitions list them. */
207 {192const PUBLISH_PROVIDER_OPTIONS = sourceControlClients.definitions.map((definition) => ({
208 value: "forgejo",193 value: definition.kind,
209 label: "Forgejo / Gitea",194 label: definition.pickerLabel,
210 description: "Your signed-in server",195 description: definition.publishDescription,
211 host: "your server",196 pathPlaceholder: definition.repositoryPathHint,
212 pathPlaceholder: "owner/repo",197 definition,
213 Icon: ForgejoIcon,198 Icon: sourceControlIcon(definition),
214 },199}));
215 {200
216 value: "github",201function publishProviderOption(provider: SourceControlProviderKind) {
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) {
265 return (202 return (
266 PUBLISH_PROVIDER_OPTIONS.find((option) => option.value === provider) ??203 PUBLISH_PROVIDER_OPTIONS.find((option) => option.value === provider) ??
267 PUBLISH_PROVIDER_OPTIONS[0]204 PUBLISH_PROVIDER_OPTIONS[0]!
268 );205 );
269}206}
270207
271function isPublishProviderKind(provider: string): provider is PublishProviderKind {
272 return PUBLISH_PROVIDER_OPTIONS.some((option) => option.value === provider);
273}
274
275function getPublishProviderReadiness(input: {208function getPublishProviderReadiness(input: {
276 provider: PublishProviderKind;209 provider: SourceControlProviderKind;
277 sourceControlProviders: ReadonlyArray<SourceControlProviderDiscoveryItem>;210 sourceControlProviders: ReadonlyArray<SourceControlProviderDiscoveryItem>;
278}): { readonly ready: boolean; readonly hint: string | null } {211}): { readonly ready: boolean; readonly hint: string | null } {
279 const discovered = input.sourceControlProviders.find(212 const discovered = input.sourceControlProviders.find(
...@@ -553,7 +486,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -553,7 +486,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
553 }),486 }),
554 );487 );
555 const [selectedPublishProvider, setSelectedPublishProvider] =488 const [selectedPublishProvider, setSelectedPublishProvider] =
556 useState<PublishProviderKind | null>(null);489 useState<SourceControlProviderKind | null>(null);
557 const [publishRepositoryOverride, setPublishRepositoryOverride] = useState<string | null>(null);490 const [publishRepositoryOverride, setPublishRepositoryOverride] = useState<string | null>(null);
558 const [publishVisibility, setPublishVisibility] =491 const [publishVisibility, setPublishVisibility] =
559 useState<SourceControlRepositoryVisibility>("private");492 useState<SourceControlRepositoryVisibility>("private");
...@@ -574,43 +507,32 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -574,43 +507,32 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
574 );507 );
575 const publishRepositoryAction = useSourceControlPublishRepositoryAction(sourceControlScope);508 const publishRepositoryAction = useSourceControlPublishRepositoryAction(sourceControlScope);
576 const publishAccountByProvider = useMemo(() => {509 const publishAccountByProvider = useMemo(() => {
577 const accounts: Record<PublishProviderKind, string | null> = {510 const accounts = new Map<string, string | null>();
578 github: null,
579 gitlab: null,
580 forgejo: null,
581 bitbucket: null,
582 "azure-devops": null,
583 gitcafe: null,
584 };
585 for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) {511 for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) {
586 const kind: string = provider.kind;512 accounts.set(provider.kind, Option.getOrNull(provider.auth.account));
587 if (isPublishProviderKind(kind)) {
588 accounts[kind] = Option.getOrNull(provider.auth.account);
589 }
590 }513 }
591 return accounts;514 return accounts;
592 }, [sourceControlDiscovery.data]);515 }, [sourceControlDiscovery.data]);
593 const publishProviderReadiness = useMemo(() => {516 const publishProviderReadiness = useMemo(() => {
594 const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? [];517 const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? [];
595 return Object.fromEntries(518 const readiness = new Map(
596 PUBLISH_PROVIDER_OPTIONS.map((option) => [519 PUBLISH_PROVIDER_OPTIONS.map((option) => [
597 option.value,520 option.value,
598 getPublishProviderReadiness({521 getPublishProviderReadiness({ provider: option.value, sourceControlProviders }),
599 provider: option.value,
600 sourceControlProviders,
601 }),
602 ]),522 ]),
603 ) as Record<PublishProviderKind, { readonly ready: boolean; readonly hint: string | null }>;523 );
524 return (provider: SourceControlProviderKind) =>
525 readiness.get(provider) ?? { ready: false, hint: null };
604 }, [sourceControlDiscovery.data]);526 }, [sourceControlDiscovery.data]);
605 const hasReadyPublishProvider = useMemo(527 const hasReadyPublishProvider = useMemo(
606 () => PUBLISH_PROVIDER_OPTIONS.some((option) => publishProviderReadiness[option.value].ready),528 () => PUBLISH_PROVIDER_OPTIONS.some((option) => publishProviderReadiness(option.value).ready),
607 [publishProviderReadiness],529 [publishProviderReadiness],
608 );530 );
609 const sortedPublishProviderOptions = useMemo(531 const sortedPublishProviderOptions = useMemo(
610 () =>532 () =>
611 PUBLISH_PROVIDER_OPTIONS.toSorted((left, right) => {533 PUBLISH_PROVIDER_OPTIONS.toSorted((left, right) => {
612 const leftReady = publishProviderReadiness[left.value].ready;534 const leftReady = publishProviderReadiness(left.value).ready;
613 const rightReady = publishProviderReadiness[right.value].ready;535 const rightReady = publishProviderReadiness(right.value).ready;
614 if (leftReady !== rightReady) {536 if (leftReady !== rightReady) {
615 return leftReady ? -1 : 1;537 return leftReady ? -1 : 1;
616 }538 }
...@@ -619,26 +541,26 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -619,26 +541,26 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
619 [publishProviderReadiness],541 [publishProviderReadiness],
620 );542 );
621 const firstReadyPublishProvider = sortedPublishProviderOptions.find(543 const firstReadyPublishProvider = sortedPublishProviderOptions.find(
622 (option) => publishProviderReadiness[option.value].ready,544 (option) => publishProviderReadiness(option.value).ready,
623 )?.value;545 )?.value;
624 const publishProvider =546 const publishProvider =
625 selectedPublishProvider !== null && publishProviderReadiness[selectedPublishProvider].ready547 selectedPublishProvider !== null && publishProviderReadiness(selectedPublishProvider).ready
626 ? selectedPublishProvider548 ? selectedPublishProvider
627 : (firstReadyPublishProvider ?? selectedPublishProvider ?? "github");549 : (firstReadyPublishProvider ??
628 const selectedPublishProviderReadiness = publishProviderReadiness[publishProvider];550 selectedPublishProvider ??
629 const publishRepositoryPrefill = publishAccountByProvider[publishProvider]551 sourceControlClients.get(undefined).kind);
630 ? `${publishAccountByProvider[publishProvider]}/`552 const selectedPublishProviderReadiness = publishProviderReadiness(publishProvider);
631 : "";553 const publishAccount = publishAccountByProvider.get(publishProvider) ?? null;
554 const publishRepositoryPrefill = publishAccount ? `${publishAccount}/` : "";
632 const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill;555 const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill;
633 const currentPublishProvider = publishProviderOption(publishProvider);556 const currentPublishProvider = publishProviderOption(publishProvider);
634 const publishHost =557 const publishHost = currentPublishProvider.definition.publishHost(
635 publishProvider === "forgejo"558 Option.getOrNull(
636 ? (Option.getOrNull(559 sourceControlDiscovery.data?.sourceControlProviders.find(
637 sourceControlDiscovery.data?.sourceControlProviders.find(560 (provider) => provider.kind === publishProvider,
638 (provider) => provider.kind === "forgejo",561 )?.auth.host ?? Option.none(),
639 )?.auth.host ?? Option.none(),562 ),
640 ) ?? currentPublishProvider.host)563 );
641 : currentPublishProvider.host;
642 const publishPathPlaceholder = currentPublishProvider.pathPlaceholder;564 const publishPathPlaceholder = currentPublishProvider.pathPlaceholder;
643 const publishProviderLabel = currentPublishProvider.label;565 const publishProviderLabel = currentPublishProvider.label;
644 const publishWizardSteps = ["Provider", "Repository", "Summary"] as const;566 const publishWizardSteps = ["Provider", "Repository", "Summary"] as const;
...@@ -672,7 +594,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -672,7 +594,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
672594
673 void (async () => {595 void (async () => {
674 const result = await publishRepositoryAction.run({596 const result = await publishRepositoryAction.run({
675 provider: SourceControlProviderKind.make(publishProvider),597 provider: publishProvider,
676 repository: publishRepository.trim(),598 repository: publishRepository.trim(),
677 visibility: publishVisibility,599 visibility: publishVisibility,
678 remoteName: publishRemoteName.trim() || "origin",600 remoteName: publishRemoteName.trim() || "origin",
...@@ -757,14 +679,14 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -757,14 +679,14 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
757 <RadioGroup679 <RadioGroup
758 value={publishProvider}680 value={publishProvider}
759 onValueChange={(value) => {681 onValueChange={(value) => {
760 setSelectedPublishProvider(value as PublishProviderKind);682 setSelectedPublishProvider(value as SourceControlProviderKind);
761 setPublishRepositoryOverride(null);683 setPublishRepositoryOverride(null);
762 }}684 }}
763 aria-labelledby="publish-provider-cards-label"685 aria-labelledby="publish-provider-cards-label"
764 className="grid grid-cols-2"686 className="grid grid-cols-2"
765 >687 >
766 {sortedPublishProviderOptions.map((option) => {688 {sortedPublishProviderOptions.map((option) => {
767 const readiness = publishProviderReadiness[option.value];689 const readiness = publishProviderReadiness(option.value);
768 const isSelected = publishProvider === option.value && readiness.ready;690 const isSelected = publishProvider === option.value && readiness.ready;
769 if (!readiness.ready) {691 if (!readiness.ready) {
770 return (692 return (
apps/web/src/components/ThreadStatusIndicators.tsx+9-20
...@@ -6,14 +6,12 @@ import {...@@ -6,14 +6,12 @@ import {
6import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";6import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
77
8import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests";8import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests";
9import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
10import {9import {
11 resolveEnvironmentMachineKind,10 resolveEnvironmentMachineKind,
12 type EnvironmentId,11 type EnvironmentId,
13 type ThreadLinkedPullRequest,12 type ThreadLinkedPullRequest,
14 type ThreadPullRequestLink,13 type ThreadPullRequestLink,
15 type VcsStatusResult,14 type VcsStatusResult,
16 SourceControlProviderKind,
17} from "@t3tools/contracts";15} from "@t3tools/contracts";
18import { Atom } from "effect/reactivity";16import { Atom } from "effect/reactivity";
19import { FolderGit2Icon, TerminalIcon } from "lucide-react";17import { FolderGit2Icon, TerminalIcon } from "lucide-react";
...@@ -38,7 +36,7 @@ import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../sta...@@ -38,7 +36,7 @@ import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../sta
38import { useThreadRunningTerminalIds } from "../state/terminalSessions";36import { useThreadRunningTerminalIds } from "../state/terminalSessions";
39import { vcsEnvironment } from "../state/vcs";37import { vcsEnvironment } from "../state/vcs";
40import { useUiStateStore } from "../uiStateStore";38import { useUiStateStore } from "../uiStateStore";
41import { resolveChangeRequestPresentation } from "../sourceControlPresentation";39import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
42import {40import {
43 resolveThreadLastVisitedAt,41 resolveThreadLastVisitedAt,
44 resolveThreadStatusPill,42 resolveThreadStatusPill,
...@@ -131,19 +129,10 @@ export function linkedPullRequestSnapshotStatus(...@@ -131,19 +129,10 @@ export function linkedPullRequestSnapshotStatus(
131): LinkedThreadPullRequestStatus | null {129): LinkedThreadPullRequestStatus | null {
132 const snapshot = link.snapshot;130 const snapshot = link.snapshot;
133 if (snapshot === null) return null;131 if (snapshot === null) return null;
134 // GitCafe's `/pulls/` routes look like Forgejo's, so its two hosts are told apart first.132 // A link records no host kind, so read it from the URL's change request path.
135 const kind =133 const kind = (
136 detectSourceControlProviderFromRemoteUrl(link.url)?.kind === "gitcafe"134 sourceControlClients.findByChangeRequestUrl(link.url) ?? sourceControlClients.get(undefined)
137 ? "gitcafe"135 ).kind;
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";
147 return {136 return {
148 pr: {137 pr: {
149 number: link.number,138 number: link.number,
...@@ -155,7 +144,7 @@ export function linkedPullRequestSnapshotStatus(...@@ -155,7 +144,7 @@ export function linkedPullRequestSnapshotStatus(
155 baseRef: snapshot.baseBranch,144 baseRef: snapshot.baseBranch,
156 ...(snapshot.updatedAt === null ? {} : { updatedAt: snapshot.updatedAt }),145 ...(snapshot.updatedAt === null ? {} : { updatedAt: snapshot.updatedAt }),
157 },146 },
158 sourceControlProvider: { kind: SourceControlProviderKind.make(kind), name: kind, baseUrl: "" },147 sourceControlProvider: { kind, name: kind, baseUrl: "" },
159 };148 };
160}149}
161150
...@@ -442,12 +431,12 @@ export function prStatusIndicator(...@@ -442,12 +431,12 @@ export function prStatusIndicator(
442 provider: VcsStatusResult["sourceControlProvider"] | null | undefined,431 provider: VcsStatusResult["sourceControlProvider"] | null | undefined,
443): PrStatusIndicator | null {432): PrStatusIndicator | null {
444 if (!pr) return null;433 if (!pr) return null;
445 const presentation = resolveChangeRequestPresentation(provider);434 const { shortLabel } = sourceControlClients.get(provider?.kind).changeRequest;
446 const state = resolvePullRequestState({ state: pr.state, isDraft: pr.isDraft === true });435 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}`;
449 return {438 return {
450 label: `${presentation.shortName} ${state.label.toLowerCase()}`,439 label: `${shortLabel} ${state.label.toLowerCase()}`,
451 colorClass: state.toneClassName,440 colorClass: state.toneClassName,
452 Icon: state.Icon,441 Icon: state.Icon,
453 tooltip: `${tooltipLead}: ${pr.title}`,442 tooltip: `${tooltipLead}: ${pr.title}`,
apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts+6-1
...@@ -1,6 +1,11 @@...@@ -1,6 +1,11 @@
1import { describe, expect, it } from "vite-plus/test";1import { 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
5const project = {10const project = {
6 host: "github.com",11 host: "github.com",
apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx+11-12
...@@ -1,10 +1,5 @@...@@ -1,10 +1,5 @@
1import { changeRequestUrlFor as changeRequestWebUrl } from "@t3tools/shared/changeRequestUrl";1import { pullRequestHostOf, type ScopedThreadRef } from "@t3tools/contracts";
2export { changeRequestUrlFor as changeRequestWebUrl } from "@t3tools/shared/changeRequestUrl";2import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
3import {
4 pullRequestHostOf,
5 type ScopedThreadRef,
6 type SourceControlProviderKind,
7} from "@t3tools/contracts";
8import { useAtomValue } from "@effect/atom-react";3import { useAtomValue } from "@effect/atom-react";
9import { useCallback, useEffect, useMemo, useRef, useState } from "react";4import { useCallback, useEffect, useMemo, useRef, useState } from "react";
105
...@@ -138,15 +133,19 @@ function LinkPullRequestDialog({...@@ -138,15 +133,19 @@ function LinkPullRequestDialog({
138 identity.displayName ??133 identity.displayName ??
139 (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null);134 (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null);
140 if (repository === null) return null;135 if (repository === null) return null;
141 const kind = identity.provider as SourceControlProviderKind;136 const definition = sourceControlClients.get(identity.provider ?? "unknown");
142 const host = pullRequestHostOf(identity, kind);137 const host = pullRequestHostOf(identity, definition.kind);
143 return {138 return {
144 host,139 host,
145 repository,140 repository,
146 webUrl: (number: number) =>141 webUrl: (number: number) =>
147 kind === "forgejo" && identity.webUrl142 definition.changeRequestUrl({
148 ? `${identity.webUrl.replace(/\/+$/, "")}/pulls/${number}`143 host,
149 : changeRequestWebUrl(kind, host, repository, number, identity.locator.remoteUrl),144 repository,
145 number,
146 remoteUrl: identity.locator.remoteUrl,
147 webUrl: identity.webUrl,
148 }),
150 };149 };
151 }, [environmentProjects, projectId]);150 }, [environmentProjects, projectId]);
152 const linking = usePullRequestLinking(threadRef.environmentId);151 const linking = usePullRequestLinking(threadRef.environmentId);
apps/web/src/components/pullRequest/PullRequestComposer.tsx+4-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
1/**2/**
2 * The single floating control over a pull request. Commenting on the change and submitting the3 * The single floating control over a pull request. Commenting on the change and submitting the
3 * review that carries the Code tab's line comments used to float as two buttons that crowded4 * review that carries the Code tab's line comments used to float as two buttons that crowded
...@@ -158,7 +159,9 @@ export function PullRequestComposer({...@@ -158,7 +159,9 @@ export function PullRequestComposer({
158 environmentId={environmentId}159 environmentId={environmentId}
159 reference={reference}160 reference={reference}
160 verdicts={verdicts}161 verdicts={verdicts}
161 requestChangesSummaryRequired={detail.provider === "forgejo"}162 requestChangesSummaryRequired={sourceControlClients
163 .get(detail.provider)
164 .reviewSummaryRequired("request-changes")}
162 textareaRef={reviewRef}165 textareaRef={reviewRef}
163 pending={reviewPending}166 pending={reviewPending}
164 onPendingChange={setReviewPending}167 onPendingChange={setReviewPending}
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+15-11
...@@ -83,6 +83,7 @@ import {...@@ -83,6 +83,7 @@ import {
83} from "~/state/pullRequests";83} from "~/state/pullRequests";
84import { useAtomCommand } from "~/state/use-atom-command";84import { useAtomCommand } from "~/state/use-atom-command";
85import { PullRequestStackMenu } from "./PullRequestStackMenu";85import { PullRequestStackMenu } from "./PullRequestStackMenu";
86import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
86import { PullRequestThreadLinks } from "./PullRequestThreadLinks";87import { PullRequestThreadLinks } from "./PullRequestThreadLinks";
87import { vcsEnvironment } from "~/state/vcs";88import { vcsEnvironment } from "~/state/vcs";
88import { formatRelativeTimeLabel } from "~/timestampFormat";89import { formatRelativeTimeLabel } from "~/timestampFormat";
...@@ -302,7 +303,7 @@ const openNumberContextMenu = (...@@ -302,7 +303,7 @@ const openNumberContextMenu = (
302 event.stopPropagation();303 event.stopPropagation();
303 void showPullRequestLinkContextMenu({304 void showPullRequestLinkContextMenu({
304 url: detail.url,305 url: detail.url,
305 openLabel: openOnHostLabel(detail.provider),306 openLabel: openOnHostLabel(sourceControlClients.get(detail.provider)),
306 position: { x: event.clientX, y: event.clientY },307 position: { x: event.clientX, y: event.clientY },
307 });308 });
308};309};
...@@ -732,16 +733,18 @@ export function PullRequestDetailPanel({...@@ -732,16 +733,18 @@ export function PullRequestDetailPanel({
732 if (detail?.autoMergeMethod !== undefined) setMergeMethod(detail.autoMergeMethod);733 if (detail?.autoMergeMethod !== undefined) setMergeMethod(detail.autoMergeMethod);
733 }, [detail?.autoMergeMethod, pullRequestKey]);734 }, [detail?.autoMergeMethod, pullRequestKey]);
734 const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url);735 const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url);
736 const hostClient = sourceControlClients.get(detail?.provider);
735 const markdownContext = useMemo(737 const markdownContext = useMemo(
736 () => ({ repositoryUrl: detail?.provider === "github" ? repositoryUrl : null, threadRef }),738 () => ({
737 [detail?.provider, repositoryUrl, threadRef],739 repositoryUrl:
740 repositoryUrl === null ? null : hostClient.referenceAutolinkRepositoryUrl(repositoryUrl),
741 threadRef,
742 }),
743 [hostClient, repositoryUrl, threadRef],
738 );744 );
739 const authorProfileUrl =745 const authorProfileUrl =
740 detail?.provider === "github" &&746 detail?.author && repositoryUrl !== null
741 detail.author !== null &&747 ? hostClient.authorProfileUrl(detail.author.login, repositoryUrl)
742 !detail.author.login.endsWith("[bot]") &&
743 repositoryUrl !== null
744 ? new URL(`/${encodeURIComponent(detail.author.login)}`, repositoryUrl).toString()
745 : null;748 : null;
746 const checkoutCommand = handoffSummary749 const checkoutCommand = handoffSummary
747 ? pullRequestCheckoutCommand(750 ? pullRequestCheckoutCommand(
...@@ -1725,7 +1728,7 @@ export function PullRequestDetailPanel({...@@ -1725,7 +1728,7 @@ export function PullRequestDetailPanel({
1725 </button>1728 </button>
1726 }1729 }
1727 />1730 />
1728 <TooltipPopup side="top">{openOnHostLabel(detail.provider)}</TooltipPopup>1731 <TooltipPopup side="top">{openOnHostLabel(hostClient)}</TooltipPopup>
1729 </Tooltip>1732 </Tooltip>
1730 </>1733 </>
1731 ) : null}1734 ) : null}
...@@ -1780,7 +1783,7 @@ export function PullRequestDetailPanel({...@@ -1780,7 +1783,7 @@ export function PullRequestDetailPanel({
1780 </button>1783 </button>
1781 }1784 }
1782 />1785 />
1783 <TooltipPopup side="top">{openOnHostLabel(detail.provider)}</TooltipPopup>1786 <TooltipPopup side="top">{openOnHostLabel(hostClient)}</TooltipPopup>
1784 </Tooltip>1787 </Tooltip>
1785 <Tooltip>1788 <Tooltip>
1786 <TooltipTrigger1789 <TooltipTrigger
...@@ -1806,6 +1809,7 @@ export function PullRequestDetailPanel({...@@ -1806,6 +1809,7 @@ export function PullRequestDetailPanel({
1806 ) : null}1809 ) : null}
1807 {nativeStack ? (1810 {nativeStack ? (
1808 <PullRequestStackMenu1811 <PullRequestStackMenu
1812 hostLabel={hostClient.label}
1809 stack={nativeStack}1813 stack={nativeStack}
1810 notice={nativeStackQuery.notice}1814 notice={nativeStackQuery.notice}
1811 onRetry={nativeStackQuery.error ? nativeStackQuery.refresh : undefined}1815 onRetry={nativeStackQuery.error ? nativeStackQuery.refresh : undefined}
...@@ -2211,7 +2215,7 @@ export function PullRequestDetailPanel({...@@ -2211,7 +2215,7 @@ export function PullRequestDetailPanel({
2211 ) : null}2215 ) : null}
2212 <MenuItem onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}>2216 <MenuItem onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}>
2213 <ArrowUpRightIcon className="size-3.5" />2217 <ArrowUpRightIcon className="size-3.5" />
2214 {openOnHostLabel(detail.provider)}2218 {openOnHostLabel(hostClient)}
2215 </MenuItem>2219 </MenuItem>
2216 <MenuItem onClick={() => copyReference(detail.url, "PR link")}>2220 <MenuItem onClick={() => copyReference(detail.url, "PR link")}>
2217 <LinkIcon className="size-3.5" />2221 <LinkIcon className="size-3.5" />
apps/web/src/components/pullRequest/PullRequestRow.tsx+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
1import { SearchIcon } from "lucide-react";2import { SearchIcon } from "lucide-react";
2import { PullRequestStackPopover } from "./PullRequestStackPopover";3import { PullRequestStackPopover } from "./PullRequestStackPopover";
3import {4import {
...@@ -158,7 +159,7 @@ function PullRequestRowImpl({...@@ -158,7 +159,7 @@ function PullRequestRowImpl({
158 event.stopPropagation();159 event.stopPropagation();
159 void showPullRequestLinkContextMenu({160 void showPullRequestLinkContextMenu({
160 url: entry.url,161 url: entry.url,
161 openLabel: openOnHostLabel(entry.provider),162 openLabel: openOnHostLabel(sourceControlClients.get(entry.provider)),
162 position: { x: event.clientX, y: event.clientY },163 position: { x: event.clientX, y: event.clientY },
163 });164 });
164 }}165 }}
apps/web/src/components/pullRequest/PullRequestStackMenu.tsx+5-2
...@@ -28,6 +28,7 @@ import { PullRequestStackLayerContent } from "./PullRequestStackLayerContent";...@@ -28,6 +28,7 @@ import { PullRequestStackLayerContent } from "./PullRequestStackLayerContent";
28import { PullRequestGlyph } from "./pullRequestIcons";28import { PullRequestGlyph } from "./pullRequestIcons";
2929
30export function PullRequestStackMenu({30export function PullRequestStackMenu({
31 hostLabel,
31 stack,32 stack,
32 reference,33 reference,
33 environmentId,34 environmentId,
...@@ -39,6 +40,8 @@ export function PullRequestStackMenu({...@@ -39,6 +40,8 @@ export function PullRequestStackMenu({
39 notice,40 notice,
40 onRetry,41 onRetry,
41}: {42}: {
43 /** The host's name, which reports what it did with the stack. */
44 hostLabel: string;
42 notice?: string | null;45 notice?: string | null;
43 onRetry?: (() => void) | undefined;46 onRetry?: (() => void) | undefined;
44 stack: PullRequestStack;47 stack: PullRequestStack;
...@@ -117,7 +120,7 @@ export function PullRequestStackMenu({...@@ -117,7 +120,7 @@ export function PullRequestStackMenu({
117 title: action === "merge" ? "Stack merge request completed" : "Stack rebased",120 title: action === "merge" ? "Stack merge request completed" : "Stack rebased",
118 description:121 description:
119 action === "merge"122 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.`
121 : undefined,124 : undefined,
122 });125 });
123 }126 }
...@@ -232,7 +235,7 @@ export function PullRequestStackMenu({...@@ -232,7 +235,7 @@ export function PullRequestStackMenu({
232 </DialogTitle>235 </DialogTitle>
233 <DialogDescription>236 <DialogDescription>
234 {confirmation === "merge"237 {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.`
236 : `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.`}239 : `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.`}
237 </DialogDescription>240 </DialogDescription>
238 </DialogHeader>241 </DialogHeader>
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+4-4
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
1import { useAtomCommand } from "~/state/use-atom-command";2import { useAtomCommand } from "~/state/use-atom-command";
2import type {3import type {
3 EnvironmentId,4 EnvironmentId,
...@@ -83,10 +84,9 @@ function CommentIdentity({...@@ -83,10 +84,9 @@ function CommentIdentity({
83 detail: PullRequestDetailView;84 detail: PullRequestDetailView;
84}) {85}) {
85 const actor = comment.author;86 const actor = comment.author;
86 const profileUrl =87 const profileUrl = actor
87 detail.provider === "github" && actor && !actor.login.endsWith("[bot]")88 ? sourceControlClients.get(detail.provider).authorProfileUrl(actor.login, detail.url)
88 ? new URL(`/${encodeURIComponent(actor.login)}`, detail.url).toString()89 : null;
89 : null;
90 return (90 return (
91 <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1 text-xs">91 <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1 text-xs">
92 <PullRequestActorLabel actor={actor} profileUrl={profileUrl} className="max-w-full" />92 <PullRequestActorLabel actor={actor} profileUrl={profileUrl} className="max-w-full" />
apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx+2-1
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import type { ProjectId, ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts";1import type { ProjectId, ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts";
2import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";2import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
3import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
3import {4import {
4 resolveThreadPullRequestChains,5 resolveThreadPullRequestChains,
5 visibleThreadPullRequests,6 visibleThreadPullRequests,
...@@ -210,7 +211,7 @@ function LinkRow({...@@ -210,7 +211,7 @@ function LinkRow({
210 </TooltipTrigger>211 </TooltipTrigger>
211 <TooltipPopup>212 <TooltipPopup>
212 {stack.kind === "native"213 {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.`
214 : `${stack.size} pull requests chained by base branch.`}215 : `${stack.size} pull requests chained by base branch.`}
215 </TooltipPopup>216 </TooltipPopup>
216 </Tooltip>217 </Tooltip>
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+22-37
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
1import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
23
3import {4import {
...@@ -68,9 +69,6 @@ export function resolvePullRequestMergeMethod(...@@ -68,9 +69,6 @@ export function resolvePullRequestMergeMethod(
68 return allowed[0] ?? "merge";69 return allowed[0] ?? "merge";
69}70}
7071
71const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/;
72const bitbucketRepositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
73
74export type PullRequestPrimaryControl =72export type PullRequestPrimaryControl =
75 | "resolve"73 | "resolve"
76 | "ready"74 | "ready"
...@@ -117,32 +115,9 @@ export function pullRequestCheckoutCommand(...@@ -117,32 +115,9 @@ export function pullRequestCheckoutCommand(
117 headRepositoryNameWithOwner?: string | null,115 headRepositoryNameWithOwner?: string | null,
118 repositoryUrl?: string | null,116 repositoryUrl?: string | null,
119): string | null {117): string | null {
120 switch (provider) {118 return sourceControlClients
121 case "github":119 .get(provider)
122 return `gh pr checkout ${number}`;120 .checkoutCommand({ number, headBranch, headRepositoryNameWithOwner, repositoryUrl });
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 }
146}121}
147122
148/** Build a checkout command from identity metadata while the detail request is still pending. */123/** Build a checkout command from identity metadata while the detail request is still pending. */
...@@ -151,15 +126,23 @@ export function loadingPullRequestCheckoutCommand(...@@ -151,15 +126,23 @@ export function loadingPullRequestCheckoutCommand(
151 identity: RepositoryIdentity | null | undefined,126 identity: RepositoryIdentity | null | undefined,
152): string | null {127): string | null {
153 const host = reference.host?.trim().toLowerCase();128 const host = reference.host?.trim().toLowerCase();
154 const provider =129 // Before the detail loads, only the number is known, so only hosts whose command needs nothing
155 identity?.provider ??130 // else qualify. Without an identity, only a public hostname names the host.
156 (host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : null);131 const definition = identity?.provider
157 if (provider !== "github" && provider !== "gitlab" && provider !== "azure-devops") return null;132 ? sourceControlClients.find(identity.provider)
158 const kind = SourceControlProviderKind.make(provider);133 : host
159 if (identity?.provider !== undefined && host && pullRequestHostOf(identity, kind) !== 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 ) {
160 return null;143 return null;
161 }144 }
162 return pullRequestCheckoutCommand(kind, reference.number, "");145 return command;
163}146}
164147
165/** Activity changes only when the same host resource reports a newer revision. */148/** Activity changes only when the same host resource reports a newer revision. */
...@@ -967,7 +950,9 @@ export function buildFixFindingsHandoff(input: {...@@ -967,7 +950,9 @@ export function buildFixFindingsHandoff(input: {
967 ? ["Failing checks:", ...includedChecks.map((check) => `> ${check}`)]950 ? ["Failing checks:", ...includedChecks.map((check) => `> ${check}`)]
968 : []),951 : []),
969 ...(input.commentsTruncated952 ...(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 ]
971 : []),956 : []),
972 ...(omitted > 0 ? [`${omitted} further findings were omitted.`] : []),957 ...(omitted > 0 ? [`${omitted} further findings were omitted.`] : []),
973 ...(includedThreads.length === 0 &&958 ...(includedThreads.length === 0 &&
apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts+6-11
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import type { ContextMenuItem } from "@t3tools/contracts";1import type { ContextMenuItem } from "@t3tools/contracts";
2import {
3 type SourceControlClientDefinition,
4 UNKNOWN_SOURCE_CONTROL_CLIENT,
5} from "@t3tools/client-runtime/source-control-clients";
26
3import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";7import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
4import { readLocalApi } from "~/localApi";8import { readLocalApi } from "~/localApi";
...@@ -8,17 +12,8 @@ import { toastManager } from "../ui/toast";...@@ -8,17 +12,8 @@ import { toastManager } from "../ui/toast";
8export type PullRequestLinkContextMenuAction = "copy-link" | "open-external";12export type PullRequestLinkContextMenuAction = "copy-link" | "open-external";
913
10/** Named for the host rather than "externally": the point is where you will land. */14/** Named for the host rather than "externally": the point is where you will land. */
11const OPEN_ON_HOST_LABELS: Partial<Record<string, string>> = {15export const openOnHostLabel = (host: SourceControlClientDefinition): string =>
12 github: "Open on GitHub",16 host === UNKNOWN_SOURCE_CONTROL_CLIENT ? "Open on host" : `Open on ${host.label}`;
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";
2217
23/** Copy first: it is the reason to right-click a number rather than click it. */18/** Copy first: it is the reason to right-click a number rather than click it. */
24function pullRequestLinkContextMenuItems(19function pullRequestLinkContextMenuItems(
apps/web/src/components/settings/SourceControlSettings.tsx+5-22
...@@ -5,7 +5,6 @@ import * as Option from "effect/Option";...@@ -5,7 +5,6 @@ import * as Option from "effect/Option";
5import { useEffect, useState, type ReactNode } from "react";5import { useEffect, useState, type ReactNode } from "react";
6import type {6import type {
7 BackgroundActivitySettings,7 BackgroundActivitySettings,
8 SourceControlProviderKind,
9 SourceControlDiscoveryResult,8 SourceControlDiscoveryResult,
10 SourceControlProviderAuth,9 SourceControlProviderAuth,
11 SourceControlProviderDiscoveryItem,10 SourceControlProviderDiscoveryItem,
...@@ -46,17 +45,7 @@ import {...@@ -46,17 +45,7 @@ import {
46import { Switch } from "../ui/switch";45import { Switch } from "../ui/switch";
47import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";46import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
4847
49import {48import { GitIcon, JujutsuIcon, type Icon } from "../Icons";
50 AzureDevOpsIcon,
51 BitbucketIcon,
52 GitCafeIcon,
53 GitHubIcon,
54 GitIcon,
55 GitLabIcon,
56 ForgejoIcon,
57 JujutsuIcon,
58 type Icon,
59} from "../Icons";
60import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings";49import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings";
61import { GitHubAccountSettings } from "./GitHubAccountSettings";50import { GitHubAccountSettings } from "./GitHubAccountSettings";
62import { GitHubTokenSettings } from "./GitHubTokenSettings";51import { GitHubTokenSettings } from "./GitHubTokenSettings";
...@@ -72,21 +61,14 @@ import {...@@ -72,21 +61,14 @@ import {
72} from "./settingsLayout";61} from "./settingsLayout";
73import { searchableSetting } from "./settingsSearch";62import { searchableSetting } from "./settingsSearch";
74import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";63import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
64import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
65import { sourceControlIcon } from "~/sourceControlPresentation";
7566
76const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {67const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
77 versionControlSystems: [],68 versionControlSystems: [],
78 sourceControlProviders: [],69 sourceControlProviders: [],
79};70};
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
90const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = {72const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = {
91 git: GitIcon,73 git: GitIcon,
92 jj: JujutsuIcon,74 jj: JujutsuIcon,
...@@ -185,8 +167,9 @@ function SourceControlItemMark({...@@ -185,8 +167,9 @@ function SourceControlItemMark({
185 readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem;167 readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem;
186}) {168}) {
187 const dotClassName = itemStatusDot(item);169 const dotClassName = itemStatusDot(item);
170 const host = isProviderDiscoveryItem(item) ? sourceControlClients.find(item.kind) : undefined;
188 const Icon = isProviderDiscoveryItem(item)171 const Icon = isProviderDiscoveryItem(item)
189 ? SOURCE_CONTROL_PROVIDER_ICONS[item.kind]172 ? host && sourceControlIcon(host)
190 : VCS_ICONS[item.kind];173 : VCS_ICONS[item.kind];
191174
192 if (!Icon) {175 if (!Icon) {
apps/web/src/sourceControlPresentation.ts+44-63
...@@ -1,17 +1,10 @@...@@ -1,17 +1,10 @@
1import type { ElementType } from "react";1import type { ElementType } from "react";
2import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts";2import type { SourceControlProviderInfo } from "@t3tools/contracts";
3export {3import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
4 DEFAULT_CHANGE_REQUEST_TERMINOLOGY,4import type {
5 getChangeRequestTerminology,5 ChangeRequestTerminology,
6 resolveChangeRequestPresentation,6 SourceControlClientDefinition,
7 type ChangeRequestPresentation,7} from "@t3tools/client-runtime/source-control-clients";
8 type ChangeRequestTerminology,
9} from "@t3tools/shared/sourceControl";
10import {
11 getChangeRequestTerminology,
12 resolveChangeRequestPresentation,
13 type ChangeRequestTerminology,
14} from "@t3tools/shared/sourceControl";
15import {8import {
16 AzureDevOpsIcon,9 AzureDevOpsIcon,
17 BitbucketIcon,10 BitbucketIcon,
...@@ -22,65 +15,53 @@ import {...@@ -22,65 +15,53 @@ import {
22} from "./components/Icons";15} from "./components/Icons";
23import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";16import { 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
25export interface SourceControlPresentation {36export interface SourceControlPresentation {
37 readonly definition: SourceControlClientDefinition;
26 readonly providerName: string;38 readonly providerName: string;
27 readonly terminology: ChangeRequestTerminology;39 readonly terminology: ChangeRequestTerminology;
28 readonly Icon: ElementType<{ className?: string }>;40 readonly Icon: SourceControlIcon;
29}41}
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. */
31export function getSourceControlPresentation(56export function getSourceControlPresentation(
32 provider: SourceControlProviderInfo | null | undefined,57 provider: SourceControlProviderInfo | null | undefined,
33): SourceControlPresentation {58): SourceControlPresentation {
34 const presentation = resolveChangeRequestPresentation(provider);59 const definition = sourceControlClients.get(provider?.kind);
35 switch (presentation.icon) {60 return presentDefinition(definition, provider?.name || definition.label);
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 }
79}61}
8062
81/** For surfaces that know only the host kind, such as a change request row or filter. */63/** For surfaces that know only the host kind, such as a change request row or filter. */
82export function getSourceControlPresentationForKind(64export function getSourceControlPresentationForKind(kind: string): SourceControlPresentation {
83 kind: SourceControlProviderKind,65 const definition = sourceControlClients.get(kind);
84): SourceControlPresentation {66 return presentDefinition(definition, definition.label);
85 return getSourceControlPresentation({ kind, name: "", baseUrl: "" });
86}67}
packages/client-runtime/src/sourceControlClients.ts+1
...@@ -12,6 +12,7 @@ import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition"...@@ -12,6 +12,7 @@ import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition"
12import { makeSourceControlClientRegistry } from "@t3tools/source-control-core/client/definition";12import { makeSourceControlClientRegistry } from "@t3tools/source-control-core/client/definition";
1313
14export {14export {
15 UNKNOWN_SOURCE_CONTROL_CLIENT,
15 type ChangeRequestTerminology,16 type ChangeRequestTerminology,
16 type SourceControlClientDefinition,17 type SourceControlClientDefinition,
17} from "@t3tools/source-control-core/client/definition";18} from "@t3tools/source-control-core/client/definition";
packages/shared/src/sourceControl.ts-19
...@@ -24,11 +24,6 @@ export interface ChangeRequestTerminology {...@@ -24,11 +24,6 @@ export interface ChangeRequestTerminology {
24 readonly singular: string;24 readonly singular: string;
25}25}
2626
27export const DEFAULT_CHANGE_REQUEST_TERMINOLOGY: ChangeRequestTerminology = {
28 shortLabel: "PR",
29 singular: "pull request",
30};
31
32const GITHUB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {27const GITHUB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
33 icon: "github",28 icon: "github",
34 providerName: "GitHub",29 providerName: "GitHub",
...@@ -132,20 +127,6 @@ function resolveChangeRequestPresentationForKind(...@@ -132,20 +127,6 @@ function resolveChangeRequestPresentationForKind(
132 return resolveChangeRequestPresentation({ kind, name: "", baseUrl: "" });127 return resolveChangeRequestPresentation({ kind, name: "", baseUrl: "" });
133}128}
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
149export function getChangeRequestTerminologyForKind(130export function getChangeRequestTerminologyForKind(
150 kind: SourceControlProviderKind,131 kind: SourceControlProviderKind,
151): ChangeRequestTerminology {132): ChangeRequestTerminology {
packages/source-control-core/src/client/definition.ts+1-1
...@@ -101,7 +101,7 @@ export function defineSourceControlClient<const Definition extends SourceControl...@@ -101,7 +101,7 @@ export function defineSourceControlClient<const Definition extends SourceControl
101}101}
102102
103/** What clients show for a host they ship no definition for, including `unknown`. */103/** 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 = {
105 kind: SourceControlProviderKind.make("unknown"),105 kind: SourceControlProviderKind.make("unknown"),
106 label: "source control",106 label: "source control",
107 pickerLabel: "source control",107 pickerLabel: "source control",