| author | |
| committer | |
| log | 579159d6c6e96f9c18358e492fbf0b8af740e80c |
| tree | 7a1a8b34277431238c5a09ee1044d3f86e0010ff |
| parent | c8a603307c2d50f8fb9d302f4b51f20fd9f91b33 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 25 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; | ||
| 18 | import { connectionStatusText } from "@t3tools/client-runtime/connection"; | 26 | import { connectionStatusText } from "@t3tools/client-runtime/connection"; |
| 19 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; | 27 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| 20 | import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; | 28 | import { 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"; |
| 190 | import { | 198 | import { GitHubIcon } from "./Icons"; |
| 191 | AzureDevOpsIcon, | 199 | import { sourceControlIcon } from "~/sourceControlPresentation"; |
| 192 | BitbucketIcon, | ||
| 193 | GitCafeIcon, | ||
| 194 | GitHubIcon, | ||
| 195 | GitLabIcon, | ||
| 196 | ForgejoIcon, | ||
| 197 | } from "./Icons"; | ||
| 198 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; | 200 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 199 | import { Checkbox } from "./ui/checkbox"; | 201 | import { Checkbox } from "./ui/checkbox"; |
| 200 | import { ProjectFavicon } from "./ProjectFavicon"; | 202 | import { 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 | } |
| 259 | 261 | ||
| 260 | type AddProjectRemoteProviderKind = | ||
| 261 | | "github" | ||
| 262 | | "gitlab" | ||
| 263 | | "forgejo" | ||
| 264 | | "bitbucket" | ||
| 265 | | "azure-devops" | ||
| 266 | | "gitcafe"; | ||
| 267 | type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; | ||
| 268 | |||
| 269 | type AddProjectCloneFlow = | 262 | type 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 | }; |
| 283 | 276 | ||
| 284 | const REMOTE_PROJECT_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [ | ||
| 285 | "url", | ||
| 286 | "github", | ||
| 287 | "gitlab", | ||
| 288 | "forgejo", | ||
| 289 | "bitbucket", | ||
| 290 | "azure-devops", | ||
| 291 | "gitcafe", | ||
| 292 | ]; | ||
| 293 | const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKind> = [ | ||
| 294 | "github", | ||
| 295 | "gitlab", | ||
| 296 | "forgejo", | ||
| 297 | "bitbucket", | ||
| 298 | "azure-devops", | ||
| 299 | "gitcafe", | ||
| 300 | ]; | ||
| 301 | |||
| 302 | function 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 | |||
| 321 | function 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 | |||
| 338 | function remoteProjectSourceProvider( | ||
| 339 | source: AddProjectRemoteSource, | ||
| 340 | ): AddProjectRemoteProviderKind | null { | ||
| 341 | return source === "url" ? null : source; | ||
| 342 | } | ||
| 343 | |||
| 344 | function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: string): ReactNode { | 277 | function 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 | } |
| 362 | 282 | ||
| 363 | function projectFaviconIcon(project: Project): ReactNode { | 283 | function 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 | |||
| 376 | function sourceProviderKind(source: AddProjectRemoteSource): AddProjectRemoteProviderKind | null { | ||
| 377 | return source === "url" ? null : source; | ||
| 378 | } | ||
| 379 | |||
| 380 | function 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 | } |
| 392 | 295 | ||
| 393 | type AddProjectRemoteSourceReadiness = Record< | 296 | /** The palette's readiness, pointing at Settings by its menu path. */ |
| 394 | AddProjectRemoteSource, | 297 | function buildPaletteRemoteSourceReadiness( |
| 395 | { readonly ready: boolean; readonly hint: string | null } | ||
| 396 | >; | ||
| 397 | |||
| 398 | function 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 | } |
| 450 | 314 | ||
| 451 | function errorMessage(error: unknown): string { | 315 | function errorMessage(error: unknown): string { |
| ... | @@ -1673,13 +1537,13 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1673,13 +1537,13 @@ function OpenCommandPaletteDialog(props: { |
| 1673 | ]; | 1537 | ]; |
| 1674 | 1538 | ||
| 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; |
| 1684 | 1548 | ||
| 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 === sourceSelectionViewValue | 2220 | 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.groups | 2226 | ? changeThemeItem.groups |
| ... | @@ -2606,7 +2470,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -2606,7 +2470,7 @@ function OpenCommandPaletteDialog(props: { |
| 2606 | return; | 2470 | return; |
| 2607 | } | 2471 | } |
| 2608 | 2472 | ||
| 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"; |
| 6 | import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; | 6 | import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; |
| 7 | import { | 7 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; |
| 8 | DEFAULT_CHANGE_REQUEST_TERMINOLOGY, | 8 | import type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients"; |
| 9 | getChangeRequestTerminology, | ||
| 10 | type ChangeRequestTerminology, | ||
| 11 | } from "../sourceControlPresentation"; | ||
| 12 | 9 | ||
| 13 | export type GitActionIconName = "commit" | "push" | "pr"; | 10 | export type GitActionIconName = "commit" | "push" | "pr"; |
| 14 | 11 | ||
| ... | @@ -68,9 +65,7 @@ export function resolveGitActionResultToastTiming( | ... | @@ -68,9 +65,7 @@ export function resolveGitActionResultToastTiming( |
| 68 | function resolveChangeRequestTerminology( | 65 | function resolveChangeRequestTerminology( |
| 69 | gitStatus: VcsStatusResult | null, | 66 | gitStatus: VcsStatusResult | null, |
| 70 | ): ChangeRequestTerminology { | 67 | ): ChangeRequestTerminology { |
| 71 | return gitStatus?.sourceControlProvider | 68 | return sourceControlClients.get(gitStatus?.sourceControlProvider?.kind).changeRequest; |
| 72 | ? getChangeRequestTerminology(gitStatus.sourceControlProvider) | ||
| 73 | : DEFAULT_CHANGE_REQUEST_TERMINOLOGY; | ||
| 74 | } | 69 | } |
| 75 | 70 | ||
| 76 | export function resolveGitActionProgressPresentation(input: { | 71 | export 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; |
| 410 | 405 | ||
| 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"; |
| 46 | import { Radio as RadioPrimitive } from "@base-ui/react/radio"; | 46 | import { Radio as RadioPrimitive } from "@base-ui/react/radio"; |
| 47 | import { | ||
| 48 | AzureDevOpsIcon, | ||
| 49 | BitbucketIcon, | ||
| 50 | GitHubIcon, | ||
| 51 | GitLabIcon, | ||
| 52 | ForgejoIcon, | ||
| 53 | GitCafeIcon, | ||
| 54 | } from "~/components/Icons"; | ||
| 55 | import { RadioGroup } from "~/components/ui/radio-group"; | 47 | import { RadioGroup } from "~/components/ui/radio-group"; |
| 56 | import { Spinner } from "~/components/ui/spinner"; | 48 | import { Spinner } from "~/components/ui/spinner"; |
| 57 | import { Toggle, ToggleGroup } from "~/components/ui/toggle-group"; | 49 | import { 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"; |
| 134 | import { getSourceControlPresentation } from "~/sourceControlPresentation"; | 126 | import { getSourceControlPresentation, sourceControlIcon } from "~/sourceControlPresentation"; |
| 127 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; | ||
| 135 | import { useOpenLink } from "~/browser/useOpenLink"; | 128 | import { useOpenLink } from "~/browser/useOpenLink"; |
| 136 | 129 | ||
| 137 | interface GitActionsControlProps { | 130 | interface GitActionsControlProps { |
| ... | @@ -158,14 +151,6 @@ interface PendingDefaultBranchAction { | ... | @@ -158,14 +151,6 @@ interface PendingDefaultBranchAction { |
| 158 | filePaths?: string[]; | 151 | filePaths?: string[]; |
| 159 | } | 152 | } |
| 160 | 153 | ||
| 161 | type PublishProviderKind = | ||
| 162 | | "github" | ||
| 163 | | "gitlab" | ||
| 164 | | "forgejo" | ||
| 165 | | "bitbucket" | ||
| 166 | | "azure-devops" | ||
| 167 | | "gitcafe"; | ||
| 168 | |||
| 169 | type GitActionToastId = ReturnType<typeof toastManager.add>; | 154 | type GitActionToastId = ReturnType<typeof toastManager.add>; |
| 170 | 155 | ||
| 171 | interface RunGitActionWithToastInput { | 156 | interface RunGitActionWithToastInput { |
| ... | @@ -203,77 +188,25 @@ function requestVcsStatusRefresh( | ... | @@ -203,77 +188,25 @@ function requestVcsStatusRefresh( |
| 203 | } | 188 | } |
| 204 | const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; | 189 | const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; |
| 205 | 190 | ||
| 206 | const PUBLISH_PROVIDER_OPTIONS = [ | 191 | /** Every built-in host, in the order the built-in definitions list them. */ |
| 207 | { | 192 | const 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", | 201 | function 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 | |||
| 264 | function 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 | } |
| 270 | 207 | ||
| 271 | function isPublishProviderKind(provider: string): provider is PublishProviderKind { | ||
| 272 | return PUBLISH_PROVIDER_OPTIONS.some((option) => option.value === provider); | ||
| 273 | } | ||
| 274 | |||
| 275 | function getPublishProviderReadiness(input: { | 208 | function 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].ready | 547 | selectedPublishProvider !== null && publishProviderReadiness(selectedPublishProvider).ready |
| 626 | ? selectedPublishProvider | 548 | ? 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) { |
| 672 | 594 | ||
| 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 | <RadioGroup | 679 | <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 { |
| 6 | import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; | 6 | import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; |
| 7 | 7 | ||
| 8 | import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; | 8 | import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; |
| 9 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; | ||
| 10 | import { | 9 | import { |
| 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"; |
| 18 | import { Atom } from "effect/reactivity"; | 16 | import { Atom } from "effect/reactivity"; |
| 19 | import { FolderGit2Icon, TerminalIcon } from "lucide-react"; | 17 | import { FolderGit2Icon, TerminalIcon } from "lucide-react"; |
| ... | @@ -38,7 +36,7 @@ import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../sta | ... | @@ -38,7 +36,7 @@ import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../sta |
| 38 | import { useThreadRunningTerminalIds } from "../state/terminalSessions"; | 36 | import { useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 39 | import { vcsEnvironment } from "../state/vcs"; | 37 | import { vcsEnvironment } from "../state/vcs"; |
| 40 | import { useUiStateStore } from "../uiStateStore"; | 38 | import { useUiStateStore } from "../uiStateStore"; |
| 41 | import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; | 39 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; |
| 42 | import { | 40 | import { |
| 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 | } |
| 161 | 150 | ||
| ... | @@ -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 }); |
| 447 | 436 | ||
| 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { changeRequestWebUrl, resolveLinkPullRequestInput } from "./LinkPullRequestDialog"; | 3 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; |
| 4 | |||
| 5 | import { resolveLinkPullRequestInput } from "./LinkPullRequestDialog"; | ||
| 6 | |||
| 7 | const changeRequestWebUrl = (kind: string, host: string, repository: string, number: number) => | ||
| 8 | sourceControlClients.get(kind).changeRequestUrl({ host, repository, number }); | ||
| 4 | 9 | ||
| 5 | const project = { | 10 | const 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 @@ |
| 1 | import { changeRequestUrlFor as changeRequestWebUrl } from "@t3tools/shared/changeRequestUrl"; | 1 | import { pullRequestHostOf, type ScopedThreadRef } from "@t3tools/contracts"; |
| 2 | export { changeRequestUrlFor as changeRequestWebUrl } from "@t3tools/shared/changeRequestUrl"; | 2 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; |
| 3 | import { | ||
| 4 | pullRequestHostOf, | ||
| 5 | type ScopedThreadRef, | ||
| 6 | type SourceControlProviderKind, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { useAtomValue } from "@effect/atom-react"; | 3 | import { useAtomValue } from "@effect/atom-react"; |
| 9 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 4 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 10 | 5 | ||
| ... | @@ -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.webUrl | 142 | 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 @@ |
| 1 | import { 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 the | 3 | * 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 crowded | 4 | * 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"; |
| 84 | import { useAtomCommand } from "~/state/use-atom-command"; | 84 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 85 | import { PullRequestStackMenu } from "./PullRequestStackMenu"; | 85 | import { PullRequestStackMenu } from "./PullRequestStackMenu"; |
| 86 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; | ||
| 86 | import { PullRequestThreadLinks } from "./PullRequestThreadLinks"; | 87 | import { PullRequestThreadLinks } from "./PullRequestThreadLinks"; |
| 87 | import { vcsEnvironment } from "~/state/vcs"; | 88 | import { vcsEnvironment } from "~/state/vcs"; |
| 88 | import { formatRelativeTimeLabel } from "~/timestampFormat"; | 89 | import { 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 = handoffSummary | 749 | 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 | <TooltipTrigger | 1789 | <TooltipTrigger |
| ... | @@ -1806,6 +1809,7 @@ export function PullRequestDetailPanel({ | ... | @@ -1806,6 +1809,7 @@ export function PullRequestDetailPanel({ |
| 1806 | ) : null} | 1809 | ) : null} |
| 1807 | {nativeStack ? ( | 1810 | {nativeStack ? ( |
| 1808 | <PullRequestStackMenu | 1811 | <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 @@ |
| 1 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; | ||
| 1 | import { SearchIcon } from "lucide-react"; | 2 | import { SearchIcon } from "lucide-react"; |
| 2 | import { PullRequestStackPopover } from "./PullRequestStackPopover"; | 3 | import { PullRequestStackPopover } from "./PullRequestStackPopover"; |
| 3 | import { | 4 | import { |
| ... | @@ -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"; |
| 28 | import { PullRequestGlyph } from "./pullRequestIcons"; | 28 | import { PullRequestGlyph } from "./pullRequestIcons"; |
| 29 | 29 | ||
| 30 | export function PullRequestStackMenu({ | 30 | export 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 @@ |
| 1 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; | ||
| 1 | import { useAtomCommand } from "~/state/use-atom-command"; | 2 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 2 | import type { | 3 | import 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 @@ |
| 1 | import type { ProjectId, ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts"; | 1 | import type { ProjectId, ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts"; |
| 2 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; | 2 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; |
| 3 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; | ||
| 3 | import { | 4 | import { |
| 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 @@ |
| 1 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; | ||
| 1 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 2 | 3 | ||
| 3 | import { | 4 | import { |
| ... | @@ -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 | } |
| 70 | 71 | ||
| 71 | const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; | ||
| 72 | const bitbucketRepositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; | ||
| 73 | |||
| 74 | export type PullRequestPrimaryControl = | 72 | export 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 | } |
| 147 | 122 | ||
| 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 | } |
| 164 | 147 | ||
| 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.commentsTruncated | 952 | ...(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 @@ |
| 1 | import type { ContextMenuItem } from "@t3tools/contracts"; | 1 | import type { ContextMenuItem } from "@t3tools/contracts"; |
| 2 | import { | ||
| 3 | type SourceControlClientDefinition, | ||
| 4 | UNKNOWN_SOURCE_CONTROL_CLIENT, | ||
| 5 | } from "@t3tools/client-runtime/source-control-clients"; | ||
| 2 | 6 | ||
| 3 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; | 7 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; |
| 4 | import { readLocalApi } from "~/localApi"; | 8 | import { readLocalApi } from "~/localApi"; |
| ... | @@ -8,17 +12,8 @@ import { toastManager } from "../ui/toast"; | ... | @@ -8,17 +12,8 @@ import { toastManager } from "../ui/toast"; |
| 8 | export type PullRequestLinkContextMenuAction = "copy-link" | "open-external"; | 12 | export type PullRequestLinkContextMenuAction = "copy-link" | "open-external"; |
| 9 | 13 | ||
| 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. */ |
| 11 | const OPEN_ON_HOST_LABELS: Partial<Record<string, string>> = { | 15 | export 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 | |||
| 20 | export const openOnHostLabel = (provider: string): string => | ||
| 21 | OPEN_ON_HOST_LABELS[provider] ?? "Open on host"; | ||
| 22 | 17 | ||
| 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. */ |
| 24 | function pullRequestLinkContextMenuItems( | 19 | function 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"; |
| 5 | import { useEffect, useState, type ReactNode } from "react"; | 5 | import { useEffect, useState, type ReactNode } from "react"; |
| 6 | import type { | 6 | import 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 { |
| 46 | import { Switch } from "../ui/switch"; | 45 | import { Switch } from "../ui/switch"; |
| 47 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 46 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 48 | 47 | ||
| 49 | import { | 48 | import { 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"; | ||
| 60 | import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings"; | 49 | import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings"; |
| 61 | import { GitHubAccountSettings } from "./GitHubAccountSettings"; | 50 | import { GitHubAccountSettings } from "./GitHubAccountSettings"; |
| 62 | import { GitHubTokenSettings } from "./GitHubTokenSettings"; | 51 | import { GitHubTokenSettings } from "./GitHubTokenSettings"; |
| ... | @@ -72,21 +61,14 @@ import { | ... | @@ -72,21 +61,14 @@ import { |
| 72 | } from "./settingsLayout"; | 61 | } from "./settingsLayout"; |
| 73 | import { searchableSetting } from "./settingsSearch"; | 62 | import { searchableSetting } from "./settingsSearch"; |
| 74 | import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; | 63 | import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; |
| 64 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; | ||
| 65 | import { sourceControlIcon } from "~/sourceControlPresentation"; | ||
| 75 | 66 | ||
| 76 | const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { | 67 | const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { |
| 77 | versionControlSystems: [], | 68 | versionControlSystems: [], |
| 78 | sourceControlProviders: [], | 69 | sourceControlProviders: [], |
| 79 | }; | 70 | }; |
| 80 | 71 | ||
| 81 | const 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 | |||
| 90 | const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = { | 72 | const 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]; |
| 191 | 174 | ||
| 192 | if (!Icon) { | 175 | if (!Icon) { |
apps/web/src/sourceControlPresentation.ts+44-63| ... | @@ -1,17 +1,10 @@ | ... | @@ -1,17 +1,10 @@ |
| 1 | import type { ElementType } from "react"; | 1 | import type { ElementType } from "react"; |
| 2 | import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts"; | 2 | import type { SourceControlProviderInfo } from "@t3tools/contracts"; |
| 3 | export { | 3 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; |
| 4 | DEFAULT_CHANGE_REQUEST_TERMINOLOGY, | 4 | import 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"; | ||
| 10 | import { | ||
| 11 | getChangeRequestTerminology, | ||
| 12 | resolveChangeRequestPresentation, | ||
| 13 | type ChangeRequestTerminology, | ||
| 14 | } from "@t3tools/shared/sourceControl"; | ||
| 15 | import { | 8 | import { |
| 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"; |
| 23 | import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; | 16 | import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; |
| 24 | 17 | ||
| 18 | export type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients"; | ||
| 19 | |||
| 20 | type SourceControlIcon = ElementType<{ className?: string }>; | ||
| 21 | |||
| 22 | /** Web art for each definition `icon` key; a key without art draws the change request glyph. */ | ||
| 23 | const 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 | |||
| 32 | export function sourceControlIcon(definition: SourceControlClientDefinition): SourceControlIcon { | ||
| 33 | return SOURCE_CONTROL_ICONS[definition.icon] ?? PullRequestGlyph.pullRequest; | ||
| 34 | } | ||
| 35 | |||
| 25 | export interface SourceControlPresentation { | 36 | export 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 | } |
| 30 | 42 | ||
| 43 | function 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. */ | ||
| 31 | export function getSourceControlPresentation( | 56 | export 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 | } |
| 80 | 62 | ||
| 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. */ |
| 82 | export function getSourceControlPresentationForKind( | 64 | export 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" |
| 12 | import { makeSourceControlClientRegistry } from "@t3tools/source-control-core/client/definition"; | 12 | import { makeSourceControlClientRegistry } from "@t3tools/source-control-core/client/definition"; |
| 13 | 13 | ||
| 14 | export { | 14 | export { |
| 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 | } |
| 26 | 26 | ||
| 27 | export const DEFAULT_CHANGE_REQUEST_TERMINOLOGY: ChangeRequestTerminology = { | ||
| 28 | shortLabel: "PR", | ||
| 29 | singular: "pull request", | ||
| 30 | }; | ||
| 31 | |||
| 32 | const GITHUB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { | 27 | const 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 | } |
| 134 | 129 | ||
| 135 | export 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 | |||
| 149 | export function getChangeRequestTerminologyForKind( | 130 | export 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 | } |
| 102 | 102 | ||
| 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`. */ |
| 104 | const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { | 104 | export 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", |