| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 2 | import { ChevronDownIcon } from "lucide-react"; |
| 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import { useEffect, useState, type ReactNode } from "react"; |
| 6 | import type { |
| 7 | BackgroundActivitySettings, |
| 8 | SourceControlDiscoveryResult, |
| 9 | SourceControlProviderAuth, |
| 10 | SourceControlProviderDiscoveryItem, |
| 11 | VcsDriverKind, |
| 12 | VcsDiscoveryItem, |
| 13 | } from "@t3tools/contracts"; |
| 14 | import { |
| 15 | getBackgroundActivityBaseProfile, |
| 16 | getBackgroundActivityPresetSettings, |
| 17 | resolveServerBackgroundActivitySettings, |
| 18 | } from "@t3tools/shared/backgroundActivitySettings"; |
| 19 | |
| 20 | import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; |
| 21 | import { useSettingsScope } from "./SettingsScopeContext"; |
| 22 | import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; |
| 23 | import { cn } from "../../lib/utils"; |
| 24 | import { useEnvironmentQuery } from "../../state/query"; |
| 25 | import { sourceControlEnvironment } from "../../state/sourceControl"; |
| 26 | import { Badge } from "../ui/badge"; |
| 27 | import { Button } from "../ui/button"; |
| 28 | import { Collapsible, CollapsibleContent } from "../ui/collapsible"; |
| 29 | import { |
| 30 | Empty, |
| 31 | EmptyContent, |
| 32 | EmptyDescription, |
| 33 | EmptyHeader, |
| 34 | EmptyMedia, |
| 35 | EmptyTitle, |
| 36 | } from "../ui/empty"; |
| 37 | import { Skeleton } from "../ui/skeleton"; |
| 38 | import { |
| 39 | NumberField, |
| 40 | NumberFieldDecrement, |
| 41 | NumberFieldGroup, |
| 42 | NumberFieldIncrement, |
| 43 | NumberFieldInput, |
| 44 | } from "../ui/number-field"; |
| 45 | import { Switch } from "../ui/switch"; |
| 46 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 47 | |
| 48 | import { GitIcon, JujutsuIcon, type Icon } from "../Icons"; |
| 49 | import { SourceControlHostSettings } from "./SourceControlHostSettings"; |
| 50 | import { GitHubAccountSettings } from "./GitHubAccountSettings"; |
| 51 | import { GitHubTokenSettings } from "./GitHubTokenSettings"; |
| 52 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; |
| 53 | import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings"; |
| 54 | import { |
| 55 | PolicyTooltip, |
| 56 | SettingResetButton, |
| 57 | SettingsPageContainer, |
| 58 | SettingsSearchTarget, |
| 59 | SettingsSection, |
| 60 | useSettingsSearchTargetId, |
| 61 | } from "./settingsLayout"; |
| 62 | import { searchableSetting, sourceControlHostSettingsSearchId } from "./settingsSearch"; |
| 63 | import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; |
| 64 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; |
| 65 | import { sourceControlIcon } from "~/sourceControlPresentation"; |
| 66 | |
| 67 | const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { |
| 68 | versionControlSystems: [], |
| 69 | sourceControlProviders: [], |
| 70 | }; |
| 71 | |
| 72 | const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = { |
| 73 | git: GitIcon, |
| 74 | jj: JujutsuIcon, |
| 75 | }; |
| 76 | |
| 77 | const SOURCE_CONTROL_SKELETON_ROWS = ["primary", "secondary"] as const; |
| 78 | const GIT_FETCH_INTERVAL_STEP_SECONDS = 5; |
| 79 | type BackgroundActivityOverridePatch = Partial<{ |
| 80 | [K in keyof BackgroundActivitySettings["overrides"]]: |
| 81 | | BackgroundActivitySettings["overrides"][K] |
| 82 | | undefined; |
| 83 | }>; |
| 84 | |
| 85 | function durationToSeconds(duration: Duration.Duration): number { |
| 86 | return Math.round(Duration.toMillis(duration) / 1_000); |
| 87 | } |
| 88 | |
| 89 | function normalizeFetchIntervalSeconds(value: number | null): number { |
| 90 | if (value === null || !Number.isFinite(value)) { |
| 91 | return 0; |
| 92 | } |
| 93 | return Math.max(0, Math.round(value)); |
| 94 | } |
| 95 | |
| 96 | function backgroundActivityOverrideSettings( |
| 97 | current: BackgroundActivitySettings, |
| 98 | overrides: BackgroundActivityOverridePatch, |
| 99 | ) { |
| 100 | const nextOverrides: BackgroundActivityOverridePatch = { |
| 101 | ...current.overrides, |
| 102 | ...overrides, |
| 103 | }; |
| 104 | for (const [key, value] of Object.entries(nextOverrides)) { |
| 105 | if (value === undefined) { |
| 106 | delete nextOverrides[key as keyof typeof nextOverrides]; |
| 107 | } |
| 108 | } |
| 109 | return { |
| 110 | backgroundActivity: { |
| 111 | schemaVersion: 1 as const, |
| 112 | profile: "custom" as const, |
| 113 | baseProfile: getBackgroundActivityBaseProfile(current), |
| 114 | overrides: nextOverrides as BackgroundActivitySettings["overrides"], |
| 115 | }, |
| 116 | }; |
| 117 | } |
| 118 | |
| 119 | function optionLabel(value: Option.Option<string>): string | null { |
| 120 | return Option.getOrNull(value); |
| 121 | } |
| 122 | |
| 123 | function isProviderDiscoveryItem( |
| 124 | item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem, |
| 125 | ): item is SourceControlProviderDiscoveryItem { |
| 126 | return "auth" in item; |
| 127 | } |
| 128 | |
| 129 | function isVcsNotReady(item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem): boolean { |
| 130 | return !isProviderDiscoveryItem(item) && !item.implemented; |
| 131 | } |
| 132 | |
| 133 | function authPresentation(auth: SourceControlProviderAuth): { |
| 134 | readonly label: string; |
| 135 | readonly badge: "warning" | null; |
| 136 | } { |
| 137 | if (auth.status === "authenticated") { |
| 138 | return { label: "Authenticated", badge: null }; |
| 139 | } |
| 140 | if (auth.status === "unauthenticated") { |
| 141 | return { label: "Not authenticated", badge: "warning" }; |
| 142 | } |
| 143 | return { label: "Status unknown", badge: null }; |
| 144 | } |
| 145 | |
| 146 | function RedactedAccount(props: { readonly account: string | null }) { |
| 147 | return ( |
| 148 | <RedactedSensitiveText |
| 149 | value={props.account} |
| 150 | ariaLabel="Toggle source control account visibility" |
| 151 | revealTooltip="Click to reveal account" |
| 152 | hideTooltip="Click to hide account" |
| 153 | /> |
| 154 | ); |
| 155 | } |
| 156 | |
| 157 | function itemStatusDot(item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem): string { |
| 158 | if (isVcsNotReady(item)) return "bg-muted-foreground/35"; |
| 159 | if (item.status !== "available") return "bg-warning"; |
| 160 | if (isProviderDiscoveryItem(item) && item.auth.status !== "authenticated") return "bg-warning"; |
| 161 | return "bg-success"; |
| 162 | } |
| 163 | |
| 164 | function SourceControlItemMark({ |
| 165 | item, |
| 166 | }: { |
| 167 | readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; |
| 168 | }) { |
| 169 | const dotClassName = itemStatusDot(item); |
| 170 | const host = isProviderDiscoveryItem(item) ? sourceControlClients.find(item.kind) : undefined; |
| 171 | const Icon = isProviderDiscoveryItem(item) |
| 172 | ? host && sourceControlIcon(host) |
| 173 | : VCS_ICONS[item.kind]; |
| 174 | |
| 175 | if (!Icon) { |
| 176 | return <span className={cn("size-2 shrink-0 rounded-full", dotClassName)} aria-hidden />; |
| 177 | } |
| 178 | |
| 179 | return ( |
| 180 | <span className="relative inline-flex size-5 shrink-0 items-center justify-center"> |
| 181 | <Icon className="size-4.5 text-foreground/80" aria-hidden /> |
| 182 | <span |
| 183 | className={cn( |
| 184 | "pointer-events-none absolute -left-0.5 -top-0.5 size-2 rounded-full ring-2 ring-background", |
| 185 | dotClassName, |
| 186 | )} |
| 187 | aria-hidden |
| 188 | /> |
| 189 | </span> |
| 190 | ); |
| 191 | } |
| 192 | |
| 193 | function itemSummary({ |
| 194 | item, |
| 195 | auth, |
| 196 | authAccount, |
| 197 | }: { |
| 198 | readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; |
| 199 | readonly auth: SourceControlProviderAuth | null; |
| 200 | readonly authAccount: string | null; |
| 201 | }) { |
| 202 | if (isVcsNotReady(item)) { |
| 203 | return <span>Support for {item.label} is coming soon.</span>; |
| 204 | } |
| 205 | |
| 206 | if (item.status !== "available") { |
| 207 | return <span>Not available on this server: {item.installHint}</span>; |
| 208 | } |
| 209 | |
| 210 | if (auth) { |
| 211 | if (auth.status === "authenticated") { |
| 212 | // The server names the account its requests use, Settings choice included, and |
| 213 | // says when an environment token overrides it. |
| 214 | const authDetail = optionLabel(auth.detail); |
| 215 | return ( |
| 216 | <> |
| 217 | <span>Authenticated</span> |
| 218 | {authAccount ? ( |
| 219 | <> |
| 220 | <span aria-hidden>as</span> |
| 221 | <RedactedAccount account={authAccount} /> |
| 222 | </> |
| 223 | ) : null} |
| 224 | {authDetail ? <span>· {authDetail}</span> : null} |
| 225 | </> |
| 226 | ); |
| 227 | } |
| 228 | |
| 229 | // API integrations have no CLI to sign in with; an unverified saved credential falls |
| 230 | // through to the "could not verify" detail instead of repeating the setup hint. |
| 231 | if (!item.executable && auth.status === "unauthenticated") { |
| 232 | return <span>Available. {item.installHint}</span>; |
| 233 | } |
| 234 | |
| 235 | // Signed in, but every login is turned off here: the fix is the switch below, not the CLI. |
| 236 | if (auth.status === "unauthenticated" && auth.accounts?.some((entry) => entry.authenticated)) { |
| 237 | return <span>{optionLabel(auth.detail) ?? `Every ${item.label} host is turned off.`}</span>; |
| 238 | } |
| 239 | |
| 240 | if (auth.status === "unauthenticated") { |
| 241 | return ( |
| 242 | <span> |
| 243 | {item.label} is not authenticated on this server. Sign in or configure credentials using |
| 244 | the <code className="rounded bg-muted px-1 py-px text-2xs">{item.executable}</code> tool |
| 245 | on the server host to enable change request features. |
| 246 | </span> |
| 247 | ); |
| 248 | } |
| 249 | const authDetail = optionLabel(auth.detail); |
| 250 | return ( |
| 251 | <span> |
| 252 | Could not verify {item.label}. {authDetail ?? item.installHint} |
| 253 | </span> |
| 254 | ); |
| 255 | } |
| 256 | |
| 257 | return <span>Available</span>; |
| 258 | } |
| 259 | |
| 260 | function DiscoveryItemRow({ |
| 261 | item, |
| 262 | children, |
| 263 | }: { |
| 264 | readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; |
| 265 | readonly children?: ReactNode; |
| 266 | }) { |
| 267 | const version = optionLabel(item.version); |
| 268 | const enabled = isProviderDiscoveryItem(item) |
| 269 | ? item.status === "available" && item.auth.status === "authenticated" |
| 270 | : item.status === "available" && item.implemented; |
| 271 | const auth = isProviderDiscoveryItem(item) ? item.auth : null; |
| 272 | const authStatus = auth ? authPresentation(auth) : null; |
| 273 | const authAccount = auth ? optionLabel(auth.account) : null; |
| 274 | const [isExpanded, setIsExpanded] = useState(false); |
| 275 | const hasDetails = children !== undefined; |
| 276 | const searchTargetId = useSettingsSearchTargetId(); |
| 277 | |
| 278 | useEffect(() => { |
| 279 | if ( |
| 280 | (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) || |
| 281 | searchTargetId === sourceControlHostSettingsSearchId(item.kind) || |
| 282 | (item.kind === "github" && searchTargetId === searchableSetting("github-accounts").id) |
| 283 | ) { |
| 284 | setIsExpanded(true); |
| 285 | } |
| 286 | }, [item.kind, searchTargetId]); |
| 287 | |
| 288 | return ( |
| 289 | <div |
| 290 | className={cn( |
| 291 | "first:rounded-t-xl last:rounded-b-xl transition-colors hover:bg-muted/20", |
| 292 | isVcsNotReady(item) && "opacity-80", |
| 293 | )} |
| 294 | > |
| 295 | <div className="px-3 py-3 sm:px-4"> |
| 296 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
| 297 | <div className="min-w-0 flex-1 space-y-1"> |
| 298 | <div className="flex min-w-0 flex-wrap items-center gap-2"> |
| 299 | <SourceControlItemMark item={item} /> |
| 300 | <span className="truncate text-sm font-medium text-foreground">{item.label}</span> |
| 301 | {version ? <code className="text-xs text-muted-foreground">{version}</code> : null} |
| 302 | {isVcsNotReady(item) ? ( |
| 303 | <Badge variant="warning" size="sm"> |
| 304 | Coming Soon |
| 305 | </Badge> |
| 306 | ) : null} |
| 307 | {authStatus?.badge ? ( |
| 308 | <Badge variant={authStatus.badge} size="sm"> |
| 309 | {authStatus.label} |
| 310 | </Badge> |
| 311 | ) : null} |
| 312 | </div> |
| 313 | <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-xs leading-normal text-muted-foreground/80"> |
| 314 | {itemSummary({ item, auth, authAccount })} |
| 315 | </p> |
| 316 | </div> |
| 317 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> |
| 318 | {hasDetails ? ( |
| 319 | <Button |
| 320 | size="icon-xs" |
| 321 | variant="ghost-muted" |
| 322 | onClick={() => setIsExpanded((open) => !open)} |
| 323 | aria-expanded={isExpanded} |
| 324 | aria-label={`Toggle ${item.label} details`} |
| 325 | > |
| 326 | <ChevronDownIcon |
| 327 | className={cn("size-3.5 transition-transform", isExpanded && "rotate-180")} |
| 328 | /> |
| 329 | </Button> |
| 330 | ) : null} |
| 331 | {!isVcsNotReady(item) ? ( |
| 332 | <Switch checked={enabled} disabled aria-label={`${item.label} availability`} /> |
| 333 | ) : null} |
| 334 | </div> |
| 335 | </div> |
| 336 | </div> |
| 337 | |
| 338 | {hasDetails ? ( |
| 339 | <Collapsible open={isExpanded} onOpenChange={setIsExpanded}> |
| 340 | <CollapsibleContent> |
| 341 | <div className="px-3 pb-4 pt-1 sm:px-4">{children}</div> |
| 342 | </CollapsibleContent> |
| 343 | </Collapsible> |
| 344 | ) : null} |
| 345 | </div> |
| 346 | ); |
| 347 | } |
| 348 | |
| 349 | function GitFetchIntervalSettings() { |
| 350 | const settings = useScopedSettings(); |
| 351 | const updateSettings = useUpdateScopedSettings(); |
| 352 | const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); |
| 353 | const automaticGitFetchIntervalSeconds = durationToSeconds( |
| 354 | resolvedBackgroundActivity.automaticGitFetchInterval, |
| 355 | ); |
| 356 | const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds( |
| 357 | getBackgroundActivityPresetSettings( |
| 358 | getBackgroundActivityBaseProfile(settings.backgroundActivity), |
| 359 | ).automaticGitFetchInterval, |
| 360 | ); |
| 361 | const canResetFetchInterval = |
| 362 | automaticGitFetchIntervalSeconds !== defaultAutomaticGitFetchIntervalSeconds; |
| 363 | const setting = searchableSetting("git-fetch-interval"); |
| 364 | |
| 365 | return ( |
| 366 | <SettingsSearchTarget id={setting.id} className="grid gap-3"> |
| 367 | <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> |
| 368 | <div className="min-w-0 space-y-1"> |
| 369 | <div className="flex min-w-0 items-center gap-1"> |
| 370 | <span className="text-xs font-medium text-foreground">{setting.title}</span> |
| 371 | <PolicyTooltip> |
| 372 | This interval is configured for Git only. The shared Background activity policy still |
| 373 | decides whether Git refreshes may run when the timer fires. Custom intervals appear as |
| 374 | Advanced in General settings. |
| 375 | </PolicyTooltip> |
| 376 | <span |
| 377 | className={cn( |
| 378 | "inline-flex size-5 shrink-0 items-center justify-center transition-opacity", |
| 379 | canResetFetchInterval ? "opacity-100" : "pointer-events-none opacity-0", |
| 380 | )} |
| 381 | aria-hidden={!canResetFetchInterval} |
| 382 | > |
| 383 | {canResetFetchInterval ? ( |
| 384 | <SettingResetButton |
| 385 | label="fetch interval" |
| 386 | onClick={() => |
| 387 | updateSettings( |
| 388 | backgroundActivityOverrideSettings(settings.backgroundActivity, { |
| 389 | automaticGitFetchInterval: undefined, |
| 390 | }), |
| 391 | ) |
| 392 | } |
| 393 | /> |
| 394 | ) : null} |
| 395 | </span> |
| 396 | </div> |
| 397 | <p className="max-w-2xl text-xs leading-relaxed text-muted-foreground"> |
| 398 | Refresh remote branches in the background. Set to 0 to avoid automatic Git prompts. |
| 399 | </p> |
| 400 | </div> |
| 401 | <div className="flex shrink-0 items-center gap-2"> |
| 402 | <NumberField |
| 403 | value={automaticGitFetchIntervalSeconds} |
| 404 | min={0} |
| 405 | step={GIT_FETCH_INTERVAL_STEP_SECONDS} |
| 406 | size="sm" |
| 407 | className="w-32" |
| 408 | onValueChange={(value) => |
| 409 | updateSettings( |
| 410 | backgroundActivityOverrideSettings(settings.backgroundActivity, { |
| 411 | automaticGitFetchInterval: Duration.seconds(normalizeFetchIntervalSeconds(value)), |
| 412 | }), |
| 413 | ) |
| 414 | } |
| 415 | > |
| 416 | <NumberFieldGroup> |
| 417 | <NumberFieldDecrement aria-label="Decrease fetch interval" /> |
| 418 | <NumberFieldInput aria-label="Automatic Git fetch interval in seconds" /> |
| 419 | <NumberFieldIncrement aria-label="Increase fetch interval" /> |
| 420 | </NumberFieldGroup> |
| 421 | </NumberField> |
| 422 | <span className="text-xs text-muted-foreground">seconds</span> |
| 423 | </div> |
| 424 | </div> |
| 425 | </SettingsSearchTarget> |
| 426 | ); |
| 427 | } |
| 428 | |
| 429 | function SourceControlSectionSkeleton({ |
| 430 | title, |
| 431 | headerAction, |
| 432 | }: { |
| 433 | readonly title: string; |
| 434 | readonly headerAction?: ReactNode; |
| 435 | }) { |
| 436 | return ( |
| 437 | <SettingsSection title={title} headerAction={headerAction}> |
| 438 | {SOURCE_CONTROL_SKELETON_ROWS.map((row) => ( |
| 439 | <div key={row} className="first:rounded-t-xl last:rounded-b-xl px-3 py-3 sm:px-4"> |
| 440 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
| 441 | <div className="min-w-0 flex-1 space-y-2"> |
| 442 | <div className="flex items-center gap-2"> |
| 443 | <span className="relative inline-flex size-5 shrink-0 items-center justify-center"> |
| 444 | <Skeleton className="size-4.5" /> |
| 445 | <Skeleton |
| 446 | shape="pill" |
| 447 | className="pointer-events-none absolute -left-0.5 -top-0.5 size-2" |
| 448 | aria-hidden |
| 449 | /> |
| 450 | </span> |
| 451 | <Skeleton shape="pill" className="h-4 w-28" /> |
| 452 | <Skeleton shape="pill" className="h-5 w-14" /> |
| 453 | </div> |
| 454 | <Skeleton shape="pill" className="h-3 w-full max-w-xs" /> |
| 455 | </div> |
| 456 | <div className="flex shrink-0 items-center gap-2"> |
| 457 | <Skeleton className="size-7" /> |
| 458 | <Skeleton shape="pill" className="h-5 w-9" /> |
| 459 | </div> |
| 460 | </div> |
| 461 | </div> |
| 462 | ))} |
| 463 | </SettingsSection> |
| 464 | ); |
| 465 | } |
| 466 | |
| 467 | function EmptySourceControlDiscovery({ |
| 468 | error, |
| 469 | isPending, |
| 470 | onScan, |
| 471 | }: { |
| 472 | readonly error: string | null; |
| 473 | readonly isPending: boolean; |
| 474 | readonly onScan: () => void; |
| 475 | }) { |
| 476 | const hasError = error !== null; |
| 477 | |
| 478 | return ( |
| 479 | <SettingsSection id={searchableSetting("source-control").id} title="Server environment"> |
| 480 | <Empty> |
| 481 | <EmptyMedia variant="icon"> |
| 482 | <PullRequestGlyph.pullRequest /> |
| 483 | </EmptyMedia> |
| 484 | <EmptyHeader> |
| 485 | <EmptyTitle> |
| 486 | {hasError ? "Could not scan the server environment" : "Nothing detected yet"} |
| 487 | </EmptyTitle> |
| 488 | <EmptyDescription> |
| 489 | {hasError |
| 490 | ? error |
| 491 | : "Install Git on the server, add optional hosting integrations or credentials your workspace needs, then rescan."} |
| 492 | </EmptyDescription> |
| 493 | </EmptyHeader> |
| 494 | <EmptyContent> |
| 495 | <Button size="sm" variant="outline" onClick={onScan} disabled={isPending}> |
| 496 | <RefreshIcon size="sm" refreshing={isPending} /> |
| 497 | Scan |
| 498 | </Button> |
| 499 | </EmptyContent> |
| 500 | </Empty> |
| 501 | </SettingsSection> |
| 502 | ); |
| 503 | } |
| 504 | |
| 505 | export function SourceControlSettingsPanel() { |
| 506 | const { scope, environment, connectedEnvironments } = useSettingsScope(); |
| 507 | // Discovery scans one machine's tools, so it shows the representative |
| 508 | // environment (named in the section title when several are selected); |
| 509 | // the settings rows above it fan out like everywhere else. |
| 510 | const environmentId = |
| 511 | environment?.connection.phase === "connected" ? environment.environmentId : null; |
| 512 | const aggregate = scope.environmentIds.length !== 1 && connectedEnvironments.length > 1; |
| 513 | const environmentSuffix = aggregate && environment ? ` · ${environment.label}` : ""; |
| 514 | const discovery = useEnvironmentQuery( |
| 515 | environmentId === null |
| 516 | ? null |
| 517 | : sourceControlEnvironment.discovery({ |
| 518 | environmentId, |
| 519 | input: {}, |
| 520 | }), |
| 521 | ); |
| 522 | const result = discovery.data ?? EMPTY_DISCOVERY_RESULT; |
| 523 | const hasVersionControlSystems = result.versionControlSystems.length > 0; |
| 524 | const hasDiscoveryItems = hasVersionControlSystems || result.sourceControlProviders.length > 0; |
| 525 | const isInitialScanPending = discovery.isPending && discovery.data === null; |
| 526 | const handleScan = () => { |
| 527 | discovery.refresh(); |
| 528 | }; |
| 529 | /** The host's own settings form, for a host whose definition declares settings. */ |
| 530 | const hostSettingsPanel = (kind: string) => { |
| 531 | const definition = sourceControlClients.find(kind); |
| 532 | if (!definition?.settings || environmentId === null) return undefined; |
| 533 | return ( |
| 534 | <SettingsSearchTarget id={sourceControlHostSettingsSearchId(kind)}> |
| 535 | <SourceControlHostSettings |
| 536 | // Drafts belong to one environment; switching must not carry them over. |
| 537 | key={environmentId} |
| 538 | environmentId={environmentId} |
| 539 | definition={{ ...definition, settings: definition.settings }} |
| 540 | onSaved={handleScan} |
| 541 | /> |
| 542 | </SettingsSearchTarget> |
| 543 | ); |
| 544 | }; |
| 545 | const scanButton = ( |
| 546 | <Tooltip> |
| 547 | <TooltipTrigger |
| 548 | render={ |
| 549 | <Button |
| 550 | size="icon-xs" |
| 551 | variant="ghost-muted" |
| 552 | onClick={handleScan} |
| 553 | disabled={discovery.isPending} |
| 554 | aria-label="Rescan server environment" |
| 555 | > |
| 556 | <RefreshIcon refreshing={discovery.isPending} /> |
| 557 | </Button> |
| 558 | } |
| 559 | /> |
| 560 | <TooltipPopup side="top">Rescan Git and hosting integrations</TooltipPopup> |
| 561 | </Tooltip> |
| 562 | ); |
| 563 | |
| 564 | return ( |
| 565 | <SettingsPageContainer> |
| 566 | <ProjectDefaultsSettings category="source-control" /> |
| 567 | {environmentId === null ? ( |
| 568 | <SettingsSection id={searchableSetting("source-control").id} title="Server environment"> |
| 569 | <p className="px-4 py-3 text-sm text-muted-foreground"> |
| 570 | Connect an environment to inspect its version control tools and hosting integrations. |
| 571 | </p> |
| 572 | </SettingsSection> |
| 573 | ) : isInitialScanPending ? ( |
| 574 | <> |
| 575 | <SourceControlSectionSkeleton |
| 576 | title={`Version Control${environmentSuffix}`} |
| 577 | headerAction={scanButton} |
| 578 | /> |
| 579 | <SourceControlSectionSkeleton title="Source Control Providers" /> |
| 580 | </> |
| 581 | ) : hasDiscoveryItems ? ( |
| 582 | <> |
| 583 | {hasVersionControlSystems ? ( |
| 584 | <SettingsSection |
| 585 | id={searchableSetting("source-control").id} |
| 586 | title={`Version Control${environmentSuffix}`} |
| 587 | headerAction={scanButton} |
| 588 | > |
| 589 | {result.versionControlSystems.map((item) => ( |
| 590 | <DiscoveryItemRow key={`vcs:${item.kind}`} item={item}> |
| 591 | {item.kind === "git" ? <GitFetchIntervalSettings /> : undefined} |
| 592 | </DiscoveryItemRow> |
| 593 | ))} |
| 594 | </SettingsSection> |
| 595 | ) : null} |
| 596 | |
| 597 | {result.sourceControlProviders.length > 0 ? ( |
| 598 | <SettingsSection |
| 599 | id={hasVersionControlSystems ? undefined : searchableSetting("source-control").id} |
| 600 | title={ |
| 601 | hasVersionControlSystems |
| 602 | ? "Source Control Providers" |
| 603 | : `Source Control Providers${environmentSuffix}` |
| 604 | } |
| 605 | headerAction={hasVersionControlSystems ? null : scanButton} |
| 606 | > |
| 607 | {result.sourceControlProviders.map((item) => ( |
| 608 | <DiscoveryItemRow key={`provider:${item.kind}`} item={item}> |
| 609 | {item.kind === "github" ? ( |
| 610 | <SettingsSearchTarget id={searchableSetting("github-accounts").id}> |
| 611 | <div className="grid gap-6"> |
| 612 | {/* Shown even without gh: a saved token is how GitHub works without the CLI. */} |
| 613 | <GitHubTokenSettings |
| 614 | key={`token-${environmentId}`} |
| 615 | environmentId={environmentId} |
| 616 | onSaved={handleScan} |
| 617 | /> |
| 618 | {item.status === "available" ? ( |
| 619 | <GitHubAccountSettings |
| 620 | key={environmentId} |
| 621 | environmentId={environmentId} |
| 622 | auth={item.auth} |
| 623 | onSaved={handleScan} |
| 624 | /> |
| 625 | ) : null} |
| 626 | </div> |
| 627 | </SettingsSearchTarget> |
| 628 | ) : ( |
| 629 | hostSettingsPanel(item.kind) |
| 630 | )} |
| 631 | </DiscoveryItemRow> |
| 632 | ))} |
| 633 | </SettingsSection> |
| 634 | ) : null} |
| 635 | </> |
| 636 | ) : ( |
| 637 | <EmptySourceControlDiscovery |
| 638 | error={discovery.error} |
| 639 | isPending={discovery.isPending} |
| 640 | onScan={handleScan} |
| 641 | /> |
| 642 | )} |
| 643 | |
| 644 | <SourceControlWritingSettingsSection /> |
| 645 | </SettingsPageContainer> |
| 646 | ); |
| 647 | } |