import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react";import * as Duration from "effect/Duration";import * as Option from "effect/Option";import { useState, type ReactNode } from "react";import type { SourceControlProviderKind, SourceControlDiscoveryResult, SourceControlProviderAuth, SourceControlProviderDiscoveryItem, VcsDriverKind, VcsDiscoveryItem,} from "@t3tools/contracts";import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";import { cn } from "../../lib/utils";import { usePrimaryEnvironment } from "../../state/environments";import { useEnvironmentQuery } from "../../state/query";import { sourceControlEnvironment } from "../../state/sourceControl";import { Badge } from "../ui/badge";import { Button } from "../ui/button";import { Collapsible, CollapsibleContent } from "../ui/collapsible";import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle,} from "../ui/empty";import { Skeleton } from "../ui/skeleton";import { NumberField, NumberFieldDecrement, NumberFieldGroup, NumberFieldIncrement, NumberFieldInput,} from "../ui/number-field";import { Switch } from "../ui/switch";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitIcon, GitLabIcon, JujutsuIcon, type Icon,} from "../Icons";import { RedactedSensitiveText } from "./RedactedSensitiveText";import { SettingResetButton, SettingsPageContainer, SettingsSection } from "./settingsLayout"; const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { versionControlSystems: [], sourceControlProviders: [],}; const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, Icon>> = { github: GitHubIcon, gitlab: GitLabIcon, "azure-devops": AzureDevOpsIcon, bitbucket: BitbucketIcon,}; const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = { git: GitIcon, jj: JujutsuIcon,}; const SOURCE_CONTROL_SKELETON_ROWS = ["primary", "secondary"] as const;const GIT_FETCH_INTERVAL_STEP_SECONDS = 5; function durationToSeconds(duration: Duration.Duration): number { return Math.round(Duration.toMillis(duration) / 1_000);} function normalizeFetchIntervalSeconds(value: number | null): number { if (value === null || !Number.isFinite(value)) { return 0; } return Math.max(0, Math.round(value));} function optionLabel(value: Option.Option<string>): string | null { return Option.getOrNull(value);} function isProviderDiscoveryItem( item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem,): item is SourceControlProviderDiscoveryItem { return "auth" in item;} function isVcsNotReady(item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem): boolean { return !isProviderDiscoveryItem(item) && !item.implemented;} function authPresentation(auth: SourceControlProviderAuth): { readonly label: string; readonly badge: "warning" | null;} { if (auth.status === "authenticated") { return { label: "Authenticated", badge: null }; } if (auth.status === "unauthenticated") { return { label: "Not authenticated", badge: "warning" }; } return { label: "Status unknown", badge: null };} function RedactedAccount(props: { readonly account: string | null }) { return ( <RedactedSensitiveText value={props.account} ariaLabel="Toggle source control account visibility" revealTooltip="Click to reveal account" hideTooltip="Click to hide account" /> );} function itemStatusDot(item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem): string { if (isVcsNotReady(item)) return "bg-muted-foreground/35"; if (item.status !== "available") return "bg-warning"; if (isProviderDiscoveryItem(item) && item.auth.status !== "authenticated") return "bg-warning"; return "bg-success";} function SourceControlItemMark({ item,}: { readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem;}) { const dotClassName = itemStatusDot(item); const Icon = isProviderDiscoveryItem(item) ? SOURCE_CONTROL_PROVIDER_ICONS[item.kind] : VCS_ICONS[item.kind]; if (!Icon) { return <span className={cn("size-2 shrink-0 rounded-full", dotClassName)} aria-hidden />; } return ( <span className="relative inline-flex size-5 shrink-0 items-center justify-center"> <Icon className="size-4.5 text-foreground/80" aria-hidden /> <span className={cn( "pointer-events-none absolute -left-0.5 -top-0.5 size-2 rounded-full ring-2 ring-background", dotClassName, )} aria-hidden /> </span> );} function itemSummary({ item, auth, authAccount,}: { readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; readonly auth: SourceControlProviderAuth | null; readonly authAccount: string | null;}) { if (isVcsNotReady(item)) { return <span>Support for {item.label} is coming soon.</span>; } if (item.status !== "available") { return <span>Not available on this server: {item.installHint}</span>; } if (auth) { if (auth.status === "authenticated") { return ( <> <span>Authenticated</span> {authAccount ? ( <> <span aria-hidden>as</span> <RedactedAccount account={authAccount} /> </> ) : null} </> ); } if (!item.executable) { return <span>Available. {item.installHint}</span>; } if (auth.status === "unauthenticated") { return ( <span> {item.label} is not authenticated on this server. Sign in or configure credentials using the <code className="rounded bg-muted px-1 py-px text-[11px]">{item.executable}</code>{" "} tool on the server host to enable pull request features. </span> ); } return ( <span> Could not verify {item.label}. {item.installHint} </span> ); } return <span>Available</span>;} function DiscoveryItemRow({ item, children,}: { readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; readonly children?: ReactNode;}) { const version = optionLabel(item.version); const enabled = isProviderDiscoveryItem(item) ? item.status === "available" && item.auth.status === "authenticated" : item.status === "available" && item.implemented; const auth = isProviderDiscoveryItem(item) ? item.auth : null; const authStatus = auth ? authPresentation(auth) : null; const authAccount = auth ? optionLabel(auth.account) : null; const [isExpanded, setIsExpanded] = useState(false); const hasDetails = children !== undefined; return ( <div className={cn( "rounded-xl transition-colors hover:bg-muted/20", isVcsNotReady(item) && "opacity-80", )} > <div className="px-3 py-3 sm:px-4"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="min-w-0 flex-1 space-y-1"> <div className="flex min-w-0 flex-wrap items-center gap-2"> <SourceControlItemMark item={item} /> <span className="truncate text-sm font-medium tracking-[-0.005em] text-foreground"> {item.label} </span> {version ? <code className="text-xs text-muted-foreground">{version}</code> : null} {isVcsNotReady(item) ? ( <Badge variant="warning" size="sm"> Coming Soon </Badge> ) : null} {authStatus?.badge ? ( <Badge variant={authStatus.badge} size="sm"> {authStatus.label} </Badge> ) : null} </div> <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-[13px] leading-[1.45] text-muted-foreground/80"> {itemSummary({ item, auth, authAccount })} </p> </div> <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> {hasDetails ? ( <Button size="sm" variant="ghost" className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground" onClick={() => setIsExpanded((open) => !open)} aria-expanded={isExpanded} aria-label={`Toggle ${item.label} details`} > <ChevronDownIcon className={cn("size-3.5 transition-transform", isExpanded && "rotate-180")} /> </Button> ) : null} {!isVcsNotReady(item) ? ( <Switch checked={enabled} disabled aria-label={`${item.label} availability`} /> ) : null} </div> </div> </div> {hasDetails ? ( <Collapsible open={isExpanded} onOpenChange={setIsExpanded}> <CollapsibleContent> <div className="px-3 pb-4 pt-1 sm:px-4">{children}</div> </CollapsibleContent> </Collapsible> ) : null} </div> );} function GitFetchIntervalSettings() { const automaticGitFetchInterval = usePrimarySettings( (settings) => settings.automaticGitFetchInterval, ); const updateSettings = useUpdatePrimarySettings(); const automaticGitFetchIntervalSeconds = durationToSeconds(automaticGitFetchInterval); const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds( DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, ); const canResetFetchInterval = automaticGitFetchIntervalSeconds !== defaultAutomaticGitFetchIntervalSeconds; return ( <div className="grid gap-3"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div className="min-w-0 space-y-1"> <div className="flex min-w-0 items-center gap-1"> <span className="text-xs font-medium text-foreground">Fetch interval</span> <span className={cn( "inline-flex size-5 shrink-0 items-center justify-center transition-opacity", canResetFetchInterval ? "opacity-100" : "pointer-events-none opacity-0", )} aria-hidden={!canResetFetchInterval} > {canResetFetchInterval ? ( <SettingResetButton label="fetch interval" onClick={() => updateSettings({ automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, }) } /> ) : null} </span> </div> <p className="max-w-2xl text-xs leading-relaxed text-muted-foreground"> Refresh remote branch status in the background. Set this to 0 seconds if Git credentials or security keys should only be prompted by explicit Git actions. </p> </div> <div className="flex shrink-0 items-center gap-2"> <NumberField value={automaticGitFetchIntervalSeconds} min={0} step={GIT_FETCH_INTERVAL_STEP_SECONDS} size="sm" className="w-32" onValueChange={(value) => updateSettings({ automaticGitFetchInterval: Duration.seconds(normalizeFetchIntervalSeconds(value)), }) } > <NumberFieldGroup> <NumberFieldDecrement aria-label="Decrease fetch interval" /> <NumberFieldInput aria-label="Automatic Git fetch interval in seconds" /> <NumberFieldIncrement aria-label="Increase fetch interval" /> </NumberFieldGroup> </NumberField> <span className="text-xs text-muted-foreground">seconds</span> </div> </div> </div> );} function SourceControlSectionSkeleton({ title, headerAction,}: { readonly title: string; readonly headerAction?: ReactNode;}) { return ( <SettingsSection title={title} headerAction={headerAction}> {SOURCE_CONTROL_SKELETON_ROWS.map((row) => ( <div key={row} className="rounded-xl px-3 py-3 sm:px-4"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="min-w-0 flex-1 space-y-2"> <div className="flex items-center gap-2"> <span className="relative inline-flex size-5 shrink-0 items-center justify-center"> <Skeleton className="size-4.5 rounded-md" /> <Skeleton className="pointer-events-none absolute -left-0.5 -top-0.5 size-2 rounded-full ring-2 ring-background" aria-hidden /> </span> <Skeleton className="h-4 w-28 rounded-full" /> <Skeleton className="h-5 w-14 rounded-full" /> </div> <Skeleton className="h-3 w-full max-w-xs rounded-full" /> </div> <div className="flex shrink-0 items-center gap-2"> <Skeleton className="size-7 rounded-md" /> <Skeleton className="h-5 w-9 rounded-full" /> </div> </div> </div> ))} </SettingsSection> );} function EmptySourceControlDiscovery({ error, isPending, onScan,}: { readonly error: string | null; readonly isPending: boolean; readonly onScan: () => void;}) { const hasError = error !== null; return ( <SettingsSection title="Server environment"> <Empty className="min-h-88"> <EmptyMedia variant="icon"> <GitPullRequestIcon /> </EmptyMedia> <EmptyHeader> <EmptyTitle> {hasError ? "Could not scan the server environment" : "Nothing detected yet"} </EmptyTitle> <EmptyDescription> {hasError ? error : "Install Git on the server, add optional hosting integrations or credentials your workspace needs, then rescan."} </EmptyDescription> </EmptyHeader> <EmptyContent> <Button size="sm" variant="outline" className="h-8 gap-1.5 px-3 text-xs" onClick={onScan} disabled={isPending} > <RefreshCwIcon className={cn("size-3.5", isPending && "animate-spin")} /> Scan </Button> </EmptyContent> </Empty> </SettingsSection> );} export function SourceControlSettingsPanel() { const environmentId = usePrimaryEnvironment()?.environmentId ?? null; const discovery = useEnvironmentQuery( environmentId === null ? null : sourceControlEnvironment.discovery({ environmentId, input: {}, }), ); const result = discovery.data ?? EMPTY_DISCOVERY_RESULT; const hasDiscoveryItems = result.versionControlSystems.length > 0 || result.sourceControlProviders.length > 0; const isInitialScanPending = discovery.isPending && discovery.data === null; const handleScan = () => { discovery.refresh(); }; const scanButton = ( <Tooltip> <TooltipTrigger render={ <Button size="icon-xs" variant="ghost" className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" onClick={handleScan} disabled={discovery.isPending} aria-label="Rescan server environment" > <RefreshCwIcon className={cn("size-3", discovery.isPending && "animate-spin")} /> </Button> } /> <TooltipPopup side="top">Rescan Git and hosting integrations</TooltipPopup> </Tooltip> ); return ( <SettingsPageContainer> {isInitialScanPending ? ( <> <SourceControlSectionSkeleton title="Version Control" headerAction={scanButton} /> <SourceControlSectionSkeleton title="Source Control Providers" /> </> ) : hasDiscoveryItems ? ( <> {result.versionControlSystems.length > 0 ? ( <SettingsSection title="Version Control" headerAction={scanButton}> {result.versionControlSystems.map((item) => ( <DiscoveryItemRow key={`vcs:${item.kind}`} item={item}> {item.kind === "git" ? <GitFetchIntervalSettings /> : undefined} </DiscoveryItemRow> ))} </SettingsSection> ) : null} {result.sourceControlProviders.length > 0 ? ( <SettingsSection title="Source Control Providers"Lines 1–500 of 518. Load more
import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; import * as Duration from "effect/Duration"; import * as Option from "effect/Option"; import { useState, type ReactNode } from "react"; import type { SourceControlProviderKind, SourceControlDiscoveryResult, SourceControlProviderAuth, SourceControlProviderDiscoveryItem, VcsDriverKind, VcsDiscoveryItem, } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { usePrimaryEnvironment } from "../../state/environments"; import { useEnvironmentQuery } from "../../state/query"; import { sourceControlEnvironment } from "../../state/sourceControl"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from "../ui/empty"; import { Skeleton } from "../ui/skeleton"; import { NumberField, NumberFieldDecrement, NumberFieldGroup, NumberFieldIncrement, NumberFieldInput, } from "../ui/number-field"; import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitIcon, GitLabIcon, JujutsuIcon, type Icon, } from "../Icons"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { SettingResetButton, SettingsPageContainer, SettingsSection } from "./settingsLayout"; const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { versionControlSystems: [], sourceControlProviders: [], }; const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, Icon>> = { github: GitHubIcon, gitlab: GitLabIcon, "azure-devops": AzureDevOpsIcon, bitbucket: BitbucketIcon, }; const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = { git: GitIcon, jj: JujutsuIcon, }; const SOURCE_CONTROL_SKELETON_ROWS = ["primary", "secondary"] as const; const GIT_FETCH_INTERVAL_STEP_SECONDS = 5; function durationToSeconds(duration: Duration.Duration): number { return Math.round(Duration.toMillis(duration) / 1_000); } function normalizeFetchIntervalSeconds(value: number | null): number { if (value === null || !Number.isFinite(value)) { return 0; } return Math.max(0, Math.round(value)); } function optionLabel(value: Option.Option<string>): string | null { return Option.getOrNull(value); } function isProviderDiscoveryItem( item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem, ): item is SourceControlProviderDiscoveryItem { return "auth" in item; } function isVcsNotReady(item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem): boolean { return !isProviderDiscoveryItem(item) && !item.implemented; } function authPresentation(auth: SourceControlProviderAuth): { readonly label: string; readonly badge: "warning" | null; } { if (auth.status === "authenticated") { return { label: "Authenticated", badge: null }; } if (auth.status === "unauthenticated") { return { label: "Not authenticated", badge: "warning" }; } return { label: "Status unknown", badge: null }; } function RedactedAccount(props: { readonly account: string | null }) { return ( <RedactedSensitiveText value={props.account} ariaLabel="Toggle source control account visibility" revealTooltip="Click to reveal account" hideTooltip="Click to hide account" /> ); } function itemStatusDot(item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem): string { if (isVcsNotReady(item)) return "bg-muted-foreground/35"; if (item.status !== "available") return "bg-warning"; if (isProviderDiscoveryItem(item) && item.auth.status !== "authenticated") return "bg-warning"; return "bg-success"; } function SourceControlItemMark({ item, }: { readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; }) { const dotClassName = itemStatusDot(item); const Icon = isProviderDiscoveryItem(item) ? SOURCE_CONTROL_PROVIDER_ICONS[item.kind] : VCS_ICONS[item.kind]; if (!Icon) { return <span className={cn("size-2 shrink-0 rounded-full", dotClassName)} aria-hidden />; } return ( <span className="relative inline-flex size-5 shrink-0 items-center justify-center"> <Icon className="size-4.5 text-foreground/80" aria-hidden /> <span className={cn( "pointer-events-none absolute -left-0.5 -top-0.5 size-2 rounded-full ring-2 ring-background", dotClassName, )} aria-hidden /> </span> ); } function itemSummary({ item, auth, authAccount, }: { readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; readonly auth: SourceControlProviderAuth | null; readonly authAccount: string | null; }) { if (isVcsNotReady(item)) { return <span>Support for {item.label} is coming soon.</span>; } if (item.status !== "available") { return <span>Not available on this server: {item.installHint}</span>; } if (auth) { if (auth.status === "authenticated") { return ( <> <span>Authenticated</span> {authAccount ? ( <> <span aria-hidden>as</span> <RedactedAccount account={authAccount} /> </> ) : null} </> ); } if (!item.executable) { return <span>Available. {item.installHint}</span>; } if (auth.status === "unauthenticated") { return ( <span> {item.label} is not authenticated on this server. Sign in or configure credentials using the <code className="rounded bg-muted px-1 py-px text-[11px]">{item.executable}</code>{" "} tool on the server host to enable pull request features. </span> ); } return ( <span> Could not verify {item.label}. {item.installHint} </span> ); } return <span>Available</span>; } function DiscoveryItemRow({ item, children, }: { readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; readonly children?: ReactNode; }) { const version = optionLabel(item.version); const enabled = isProviderDiscoveryItem(item) ? item.status === "available" && item.auth.status === "authenticated" : item.status === "available" && item.implemented; const auth = isProviderDiscoveryItem(item) ? item.auth : null; const authStatus = auth ? authPresentation(auth) : null; const authAccount = auth ? optionLabel(auth.account) : null; const [isExpanded, setIsExpanded] = useState(false); const hasDetails = children !== undefined; return ( <div className={cn( "rounded-xl transition-colors hover:bg-muted/20", isVcsNotReady(item) && "opacity-80", )} > <div className="px-3 py-3 sm:px-4"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="min-w-0 flex-1 space-y-1"> <div className="flex min-w-0 flex-wrap items-center gap-2"> <SourceControlItemMark item={item} /> <span className="truncate text-sm font-medium tracking-[-0.005em] text-foreground"> {item.label} </span> {version ? <code className="text-xs text-muted-foreground">{version}</code> : null} {isVcsNotReady(item) ? ( <Badge variant="warning" size="sm"> Coming Soon </Badge> ) : null} {authStatus?.badge ? ( <Badge variant={authStatus.badge} size="sm"> {authStatus.label} </Badge> ) : null} </div> <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-[13px] leading-[1.45] text-muted-foreground/80"> {itemSummary({ item, auth, authAccount })} </p> </div> <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> {hasDetails ? ( <Button size="sm" variant="ghost" className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground" onClick={() => setIsExpanded((open) => !open)} aria-expanded={isExpanded} aria-label={`Toggle ${item.label} details`} > <ChevronDownIcon className={cn("size-3.5 transition-transform", isExpanded && "rotate-180")} /> </Button> ) : null} {!isVcsNotReady(item) ? ( <Switch checked={enabled} disabled aria-label={`${item.label} availability`} /> ) : null} </div> </div> </div> {hasDetails ? ( <Collapsible open={isExpanded} onOpenChange={setIsExpanded}> <CollapsibleContent> <div className="px-3 pb-4 pt-1 sm:px-4">{children}</div> </CollapsibleContent> </Collapsible> ) : null} </div> ); } function GitFetchIntervalSettings() { const automaticGitFetchInterval = usePrimarySettings( (settings) => settings.automaticGitFetchInterval, ); const updateSettings = useUpdatePrimarySettings(); const automaticGitFetchIntervalSeconds = durationToSeconds(automaticGitFetchInterval); const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds( DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, ); const canResetFetchInterval = automaticGitFetchIntervalSeconds !== defaultAutomaticGitFetchIntervalSeconds; return ( <div className="grid gap-3"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div className="min-w-0 space-y-1"> <div className="flex min-w-0 items-center gap-1"> <span className="text-xs font-medium text-foreground">Fetch interval</span> <span className={cn( "inline-flex size-5 shrink-0 items-center justify-center transition-opacity", canResetFetchInterval ? "opacity-100" : "pointer-events-none opacity-0", )} aria-hidden={!canResetFetchInterval} > {canResetFetchInterval ? ( <SettingResetButton label="fetch interval" onClick={() => updateSettings({ automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, }) } /> ) : null} </span> </div> <p className="max-w-2xl text-xs leading-relaxed text-muted-foreground"> Refresh remote branch status in the background. Set this to 0 seconds if Git credentials or security keys should only be prompted by explicit Git actions. </p> </div> <div className="flex shrink-0 items-center gap-2"> <NumberField value={automaticGitFetchIntervalSeconds} min={0} step={GIT_FETCH_INTERVAL_STEP_SECONDS} size="sm" className="w-32" onValueChange={(value) => updateSettings({ automaticGitFetchInterval: Duration.seconds(normalizeFetchIntervalSeconds(value)), }) } > <NumberFieldGroup> <NumberFieldDecrement aria-label="Decrease fetch interval" /> <NumberFieldInput aria-label="Automatic Git fetch interval in seconds" /> <NumberFieldIncrement aria-label="Increase fetch interval" /> </NumberFieldGroup> </NumberField> <span className="text-xs text-muted-foreground">seconds</span> </div> </div> </div> ); } function SourceControlSectionSkeleton({ title, headerAction, }: { readonly title: string; readonly headerAction?: ReactNode; }) { return ( <SettingsSection title={title} headerAction={headerAction}> {SOURCE_CONTROL_SKELETON_ROWS.map((row) => ( <div key={row} className="rounded-xl px-3 py-3 sm:px-4"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="min-w-0 flex-1 space-y-2"> <div className="flex items-center gap-2"> <span className="relative inline-flex size-5 shrink-0 items-center justify-center"> <Skeleton className="size-4.5 rounded-md" /> <Skeleton className="pointer-events-none absolute -left-0.5 -top-0.5 size-2 rounded-full ring-2 ring-background" aria-hidden /> </span> <Skeleton className="h-4 w-28 rounded-full" /> <Skeleton className="h-5 w-14 rounded-full" /> </div> <Skeleton className="h-3 w-full max-w-xs rounded-full" /> </div> <div className="flex shrink-0 items-center gap-2"> <Skeleton className="size-7 rounded-md" /> <Skeleton className="h-5 w-9 rounded-full" /> </div> </div> </div> ))} </SettingsSection> ); } function EmptySourceControlDiscovery({ error, isPending, onScan, }: { readonly error: string | null; readonly isPending: boolean; readonly onScan: () => void; }) { const hasError = error !== null; return ( <SettingsSection title="Server environment"> <Empty className="min-h-88"> <EmptyMedia variant="icon"> <GitPullRequestIcon /> </EmptyMedia> <EmptyHeader> <EmptyTitle> {hasError ? "Could not scan the server environment" : "Nothing detected yet"} </EmptyTitle> <EmptyDescription> {hasError ? error : "Install Git on the server, add optional hosting integrations or credentials your workspace needs, then rescan."} </EmptyDescription> </EmptyHeader> <EmptyContent> <Button size="sm" variant="outline" className="h-8 gap-1.5 px-3 text-xs" onClick={onScan} disabled={isPending} > <RefreshCwIcon className={cn("size-3.5", isPending && "animate-spin")} /> Scan </Button> </EmptyContent> </Empty> </SettingsSection> ); } export function SourceControlSettingsPanel() { const environmentId = usePrimaryEnvironment()?.environmentId ?? null; const discovery = useEnvironmentQuery( environmentId === null ? null : sourceControlEnvironment.discovery({ environmentId, input: {}, }), ); const result = discovery.data ?? EMPTY_DISCOVERY_RESULT; const hasDiscoveryItems = result.versionControlSystems.length > 0 || result.sourceControlProviders.length > 0; const isInitialScanPending = discovery.isPending && discovery.data === null; const handleScan = () => { discovery.refresh(); }; const scanButton = ( <Tooltip> <TooltipTrigger render={ <Button size="icon-xs" variant="ghost" className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" onClick={handleScan} disabled={discovery.isPending} aria-label="Rescan server environment" > <RefreshCwIcon className={cn("size-3", discovery.isPending && "animate-spin")} /> </Button> } /> <TooltipPopup side="top">Rescan Git and hosting integrations</TooltipPopup> </Tooltip> ); return ( <SettingsPageContainer> {isInitialScanPending ? ( <> <SourceControlSectionSkeleton title="Version Control" headerAction={scanButton} /> <SourceControlSectionSkeleton title="Source Control Providers" /> </> ) : hasDiscoveryItems ? ( <> {result.versionControlSystems.length > 0 ? ( <SettingsSection title="Version Control" headerAction={scanButton}> {result.versionControlSystems.map((item) => ( <DiscoveryItemRow key={`vcs:${item.kind}`} item={item}> {item.kind === "git" ? <GitFetchIntervalSettings /> : undefined} </DiscoveryItemRow> ))} </SettingsSection> ) : null} {result.sourceControlProviders.length > 0 ? ( <SettingsSection title="Source Control Providers"