import { GitPullRequestIcon, RefreshCwIcon } from "lucide-react";import { Option } from "effect";import { type ReactNode, useId } from "react";import type { SourceControlProviderKind, SourceControlDiscoveryResult, SourceControlProviderAuth, SourceControlProviderDiscoveryItem, VcsDiscoveryItem,} from "@t3tools/contracts"; import { cn } from "../../lib/utils";import { refreshSourceControlDiscovery, useSourceControlDiscovery,} from "../../lib/sourceControlDiscoveryState";import { Badge } from "../ui/badge";import { Button } from "../ui/button";import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle,} from "../ui/empty";import { Skeleton } from "../ui/skeleton";import { Switch } from "../ui/switch";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";import { GitHubIcon, type Icon } from "../Icons";import { RedactedSensitiveText } from "./RedactedSensitiveText";import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { versionControlSystems: [], sourceControlProviders: [],}; const GitLabIcon: Icon = (props) => ( <svg {...props} viewBox="0 0 32 32" fill="none"> <path d="m31.46 12.78-.04-.12-4.35-11.35A1.14 1.14 0 0 0 25.94.6c-.24 0-.47.1-.66.24-.19.15-.33.36-.39.6l-2.94 9h-11.9l-2.94-9A1.14 1.14 0 0 0 6.07.58a1.15 1.15 0 0 0-1.14.72L.58 12.68l-.05.11a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48 6.67-5h.02a8.09 8.09 0 0 0 2.7-9.36Z" fill="#E24329" /> <path d="m31.46 12.78-.04-.12a14.75 14.75 0 0 0-5.86 2.64l-9.55 7.24 6.09 4.6 6.67-5h.02a8.09 8.09 0 0 0 2.67-9.36Z" fill="#FC6D26" /> <path d="m9.9 27.14 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48-6.1-4.6-6.07 4.6Z" fill="#FCA326" /> <path d="M6.44 15.3a14.71 14.71 0 0 0-5.86-2.63l-.05.12a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 6.1-4.6-9.56-7.24Z" fill="#FC6D26" /> </svg>); const AzureDevOpsIcon: Icon = (props) => { const id = useId().replaceAll(":", ""); const gradientA = `${id}-azure-a`; const gradientB = `${id}-azure-b`; const gradientC = `${id}-azure-c`; return ( <svg {...props} viewBox="0 0 96 96"> <defs> <linearGradient id={gradientA} x1="-1032.17" x2="-1059.21" y1="145.31" y2="65.43" gradientTransform="matrix(1 0 0 -1 1075 158)" gradientUnits="userSpaceOnUse" > <stop offset="0" stopColor="#114a8b" /> <stop offset="1" stopColor="#0669bc" /> </linearGradient> <linearGradient id={gradientB} x1="-1023.73" x2="-1029.98" y1="108.08" y2="105.97" gradientTransform="matrix(1 0 0 -1 1075 158)" gradientUnits="userSpaceOnUse" > <stop offset="0" stopOpacity=".3" /> <stop offset=".07" stopOpacity=".2" /> <stop offset=".32" stopOpacity=".1" /> <stop offset=".62" stopOpacity=".05" /> <stop offset="1" stopOpacity="0" /> </linearGradient> <linearGradient id={gradientC} x1="-1027.16" x2="-997.48" y1="147.64" y2="68.56" gradientTransform="matrix(1 0 0 -1 1075 158)" gradientUnits="userSpaceOnUse" > <stop offset="0" stopColor="#3ccbf4" /> <stop offset="1" stopColor="#2892df" /> </linearGradient> </defs> <path fill={`url(#${gradientA})`} d="M33.34 6.54h26.04l-27.03 80.1a4.15 4.15 0 0 1-3.94 2.81H8.15a4.14 4.14 0 0 1-3.93-5.47L29.4 9.38a4.15 4.15 0 0 1 3.94-2.83z" /> <path fill="#0078d4" d="M71.17 60.26H29.88a1.91 1.91 0 0 0-1.3 3.31l26.53 24.76a4.17 4.17 0 0 0 2.85 1.13h23.38z" /> <path fill={`url(#${gradientB})`} d="M33.34 6.54a4.12 4.12 0 0 0-3.95 2.88L4.25 83.92a4.14 4.14 0 0 0 3.91 5.54h20.79a4.44 4.44 0 0 0 3.4-2.9l5.02-14.78 17.91 16.7a4.24 4.24 0 0 0 2.67.97h23.29L71.02 60.26H41.24L59.47 6.55z" /> <path fill={`url(#${gradientC})`} d="M66.6 9.36a4.14 4.14 0 0 0-3.93-2.82H33.65a4.15 4.15 0 0 1 3.93 2.82l25.18 74.62a4.15 4.15 0 0 1-3.93 5.48h29.02a4.15 4.15 0 0 0 3.93-5.48z" /> </svg> );}; const BitbucketIcon: Icon = (props) => { const id = useId().replaceAll(":", ""); const gradientId = `${id}-bitbucket-a`; return ( <svg {...props} viewBox="8.4 14.39 2481.29 2231.21"> <path fill="none" d="M989.97,1493.09h518.05l125.04-730.04H852.22L989.97,1493.09z" /> <path fill="#2684FF" d="M88.92,14.4C45.02,13.83,8.97,48.96,8.41,92.86c-0.06,4.61,0.28,9.22,1.02,13.77l337.48,2048.72 c8.68,51.75,53.26,89.8,105.74,90.24h1619.03c39.38,0.5,73.19-27.9,79.49-66.78l337.49-2071.78c7.03-43.34-22.41-84.17-65.75-91.2 c-4.55-0.74-9.15-1.08-13.76-1.02L88.92,14.4z M1509.99,1495.09H993.24l-139.92-731h781.89L1509.99,1495.09z" /> <linearGradient id={gradientId} gradientUnits="userSpaceOnUse" x1="945.1094" y1="1524.8389" x2="944.4923" y2="1524.1893" gradientTransform="matrix(1996.6343 0 0 -1480.3047 -1884485.625 2258195)" > <stop offset="0.18" stopColor="#0052CC" /> <stop offset="1" stopColor="#2684FF" /> </linearGradient> <path fill={`url(#${gradientId})`} d="M2379.27,763.06h-745.5l-125.12,730.42H992.31l-609.67,723.67c19.32,16.71,43.96,26,69.5,26.21h1618.13 c39.35,0.51,73.14-27.88,79.44-66.72L2379.27,763.06z" /> </svg> );}; const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, Icon>> = { github: GitHubIcon, gitlab: GitLabIcon, "azure-devops": AzureDevOpsIcon, bitbucket: BitbucketIcon,}; 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 authPresentation(auth: SourceControlProviderAuth): { readonly label: string; readonly badge: "warning" | null;} { if (auth.status === "authenticated") { return { label: "Signed in", badge: null }; } if (auth.status === "unauthenticated") { return { label: "Sign in", badge: "warning" }; } return { label: "Sign in", 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 (!item.implemented) 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] : null; 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 (!item.implemented) { return <span>Support for {item.label} is coming soon.</span>; } if (item.status !== "available") { return <span>Not found - {item.installHint}</span>; } if (auth) { if (auth.status === "authenticated") { return ( <> <span>Authenticated</span> {authAccount ? ( <> <span aria-hidden>as</span> <RedactedAccount account={authAccount} /> </> ) : null} </> ); } if (auth.status === "unauthenticated") { return <span>Sign in with the {item.executable} CLI to enable pull request actions.</span>; } return ( <span> Install and sign in with the {item.executable} CLI to enable pull request actions. </span> ); } return <span>Available</span>;} function DiscoveryItemRow({ item,}: { readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem;}) { const version = optionLabel(item.version); const enabled = item.implemented && item.status === "available"; const auth = isProviderDiscoveryItem(item) ? item.auth : null; const authStatus = auth ? authPresentation(auth) : null; const authAccount = auth ? optionLabel(auth.account) : null; return ( <div className={cn( "border-t border-border/60 first:border-t-0", !item.implemented && "opacity-80", )} > <div className="px-4 py-3.5 sm:px-5"> <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} /> <h3 className="truncate text-[13px] font-semibold tracking-[-0.01em] text-foreground"> {item.label} </h3> {version ? <code className="text-xs text-muted-foreground">{version}</code> : null} {!item.implemented ? ( <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-xs text-muted-foreground"> {itemSummary({ item, auth, authAccount })} </p> </div> <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> {item.implemented ? ( <Switch checked={enabled} disabled aria-label={`${item.label} availability`} /> ) : null} </div> </div> </div> </div> );} function SourceControlSectionSkeleton({ title, headerAction,}: { readonly title: string; readonly headerAction?: ReactNode;}) { return ( <SettingsSection title={title} headerAction={headerAction}> {Array.from({ length: 2 }, (_, index) => ( <div key={index} className="border-t border-border/60 px-4 py-3.5 first:border-t-0 sm:px-5"> <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"> <Skeleton className="size-2 rounded-full" /> <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="Detected tools"> <Empty className="min-h-88"> <EmptyMedia variant="icon"> <GitPullRequestIcon /> </EmptyMedia> <EmptyHeader> <EmptyTitle> {hasError ? "Could not scan source control" : "No source control tools found"} </EmptyTitle> <EmptyDescription> {hasError ? error : "Install a supported Git or pull request CLI, then scan again."} </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 discovery = useSourceControlDiscovery(); 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 = () => { void refreshSourceControlDiscovery(); }; 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="Scan source control tools" > <RefreshCwIcon className={cn("size-3", discovery.isPending && "animate-spin")} /> </Button> } /> <TooltipPopup side="top">Scan source control tools</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} /> ))} </SettingsSection> ) : null} {result.sourceControlProviders.length > 0 ? ( <SettingsSection title="Source Control Providers" headerAction={result.versionControlSystems.length === 0 ? scanButton : null} > {result.sourceControlProviders.map((item) => ( <DiscoveryItemRow key={`provider:${item.kind}`} item={item} /> ))} </SettingsSection> ) : null} </> ) : ( <EmptySourceControlDiscovery error={discovery.error} isPending={discovery.isPending} onScan={handleScan} /> )} </SettingsPageContainer> );}
import { GitPullRequestIcon, RefreshCwIcon } from "lucide-react";
import { Option } from "effect";
import { type ReactNode, useId } from "react";
import type {
SourceControlProviderKind,
SourceControlDiscoveryResult,
SourceControlProviderAuth,
SourceControlProviderDiscoveryItem,
VcsDiscoveryItem,
} from "@t3tools/contracts";
import { cn } from "../../lib/utils";
import {
refreshSourceControlDiscovery,
useSourceControlDiscovery,
} from "../../lib/sourceControlDiscoveryState";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "../ui/empty";
import { Skeleton } from "../ui/skeleton";
import { Switch } from "../ui/switch";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { GitHubIcon, type Icon } from "../Icons";
import { RedactedSensitiveText } from "./RedactedSensitiveText";
import { SettingsPageContainer, SettingsSection } from "./settingsLayout";
const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
versionControlSystems: [],
sourceControlProviders: [],
};
const GitLabIcon: Icon = (props) => (
<svg {...props} viewBox="0 0 32 32" fill="none">
<path
d="m31.46 12.78-.04-.12-4.35-11.35A1.14 1.14 0 0 0 25.94.6c-.24 0-.47.1-.66.24-.19.15-.33.36-.39.6l-2.94 9h-11.9l-2.94-9A1.14 1.14 0 0 0 6.07.58a1.15 1.15 0 0 0-1.14.72L.58 12.68l-.05.11a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48 6.67-5h.02a8.09 8.09 0 0 0 2.7-9.36Z"
fill="#E24329"
/>
<path
d="m31.46 12.78-.04-.12a14.75 14.75 0 0 0-5.86 2.64l-9.55 7.24 6.09 4.6 6.67-5h.02a8.09 8.09 0 0 0 2.67-9.36Z"
fill="#FC6D26"
/>
<path
d="m9.9 27.14 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48-6.1-4.6-6.07 4.6Z"
fill="#FCA326"
/>
<path
d="M6.44 15.3a14.71 14.71 0 0 0-5.86-2.63l-.05.12a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 6.1-4.6-9.56-7.24Z"
fill="#FC6D26"
/>
</svg>
);
const AzureDevOpsIcon: Icon = (props) => {
const id = useId().replaceAll(":", "");
const gradientA = `${id}-azure-a`;
const gradientB = `${id}-azure-b`;
const gradientC = `${id}-azure-c`;
return (
<svg {...props} viewBox="0 0 96 96">
<defs>
<linearGradient
id={gradientA}
x1="-1032.17"
x2="-1059.21"
y1="145.31"
y2="65.43"
gradientTransform="matrix(1 0 0 -1 1075 158)"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#114a8b" />
<stop offset="1" stopColor="#0669bc" />
</linearGradient>
<linearGradient
id={gradientB}
x1="-1023.73"
x2="-1029.98"
y1="108.08"
y2="105.97"
gradientTransform="matrix(1 0 0 -1 1075 158)"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopOpacity=".3" />
<stop offset=".07" stopOpacity=".2" />
<stop offset=".32" stopOpacity=".1" />
<stop offset=".62" stopOpacity=".05" />
<stop offset="1" stopOpacity="0" />
</linearGradient>
<linearGradient
id={gradientC}
x1="-1027.16"
x2="-997.48"
y1="147.64"
y2="68.56"
gradientTransform="matrix(1 0 0 -1 1075 158)"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#3ccbf4" />
<stop offset="1" stopColor="#2892df" />
</linearGradient>
</defs>
<path
fill={`url(#${gradientA})`}
d="M33.34 6.54h26.04l-27.03 80.1a4.15 4.15 0 0 1-3.94 2.81H8.15a4.14 4.14 0 0 1-3.93-5.47L29.4 9.38a4.15 4.15 0 0 1 3.94-2.83z"
/>
<path
fill="#0078d4"
d="M71.17 60.26H29.88a1.91 1.91 0 0 0-1.3 3.31l26.53 24.76a4.17 4.17 0 0 0 2.85 1.13h23.38z"
/>
<path
fill={`url(#${gradientB})`}
d="M33.34 6.54a4.12 4.12 0 0 0-3.95 2.88L4.25 83.92a4.14 4.14 0 0 0 3.91 5.54h20.79a4.44 4.44 0 0 0 3.4-2.9l5.02-14.78 17.91 16.7a4.24 4.24 0 0 0 2.67.97h23.29L71.02 60.26H41.24L59.47 6.55z"
/>
<path
fill={`url(#${gradientC})`}
d="M66.6 9.36a4.14 4.14 0 0 0-3.93-2.82H33.65a4.15 4.15 0 0 1 3.93 2.82l25.18 74.62a4.15 4.15 0 0 1-3.93 5.48h29.02a4.15 4.15 0 0 0 3.93-5.48z"
/>
</svg>
);
};
const BitbucketIcon: Icon = (props) => {
const id = useId().replaceAll(":", "");
const gradientId = `${id}-bitbucket-a`;
return (
<svg {...props} viewBox="8.4 14.39 2481.29 2231.21">
<path fill="none" d="M989.97,1493.09h518.05l125.04-730.04H852.22L989.97,1493.09z" />
<path
fill="#2684FF"
d="M88.92,14.4C45.02,13.83,8.97,48.96,8.41,92.86c-0.06,4.61,0.28,9.22,1.02,13.77l337.48,2048.72 c8.68,51.75,53.26,89.8,105.74,90.24h1619.03c39.38,0.5,73.19-27.9,79.49-66.78l337.49-2071.78c7.03-43.34-22.41-84.17-65.75-91.2 c-4.55-0.74-9.15-1.08-13.76-1.02L88.92,14.4z M1509.99,1495.09H993.24l-139.92-731h781.89L1509.99,1495.09z"
/>
<linearGradient
id={gradientId}
gradientUnits="userSpaceOnUse"
x1="945.1094"
y1="1524.8389"
x2="944.4923"
y2="1524.1893"
gradientTransform="matrix(1996.6343 0 0 -1480.3047 -1884485.625 2258195)"
>
<stop offset="0.18" stopColor="#0052CC" />
<stop offset="1" stopColor="#2684FF" />
</linearGradient>
<path
fill={`url(#${gradientId})`}
d="M2379.27,763.06h-745.5l-125.12,730.42H992.31l-609.67,723.67c19.32,16.71,43.96,26,69.5,26.21h1618.13 c39.35,0.51,73.14-27.88,79.44-66.72L2379.27,763.06z"
/>
</svg>
);
};
const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, Icon>> = {
github: GitHubIcon,
gitlab: GitLabIcon,
"azure-devops": AzureDevOpsIcon,
bitbucket: BitbucketIcon,
};
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 authPresentation(auth: SourceControlProviderAuth): {
readonly label: string;
readonly badge: "warning" | null;
} {
if (auth.status === "authenticated") {
return { label: "Signed in", badge: null };
}
if (auth.status === "unauthenticated") {
return { label: "Sign in", badge: "warning" };
}
return { label: "Sign in", 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 (!item.implemented) 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] : null;
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 (!item.implemented) {
return <span>Support for {item.label} is coming soon.</span>;
}
if (item.status !== "available") {
return <span>Not found - {item.installHint}</span>;
}
if (auth) {
if (auth.status === "authenticated") {
return (
<>
<span>Authenticated</span>
{authAccount ? (
<>
<span aria-hidden>as</span>
<RedactedAccount account={authAccount} />
</>
) : null}
</>
);
}
if (auth.status === "unauthenticated") {
return <span>Sign in with the {item.executable} CLI to enable pull request actions.</span>;
}
return (
<span>
Install and sign in with the {item.executable} CLI to enable pull request actions.
</span>
);
}
return <span>Available</span>;
}
function DiscoveryItemRow({
item,
}: {
readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem;
}) {
const version = optionLabel(item.version);
const enabled = item.implemented && item.status === "available";
const auth = isProviderDiscoveryItem(item) ? item.auth : null;
const authStatus = auth ? authPresentation(auth) : null;
const authAccount = auth ? optionLabel(auth.account) : null;
return (
<div
className={cn(
"border-t border-border/60 first:border-t-0",
!item.implemented && "opacity-80",
)}
>
<div className="px-4 py-3.5 sm:px-5">
<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} />
<h3 className="truncate text-[13px] font-semibold tracking-[-0.01em] text-foreground">
{item.label}
</h3>
{version ? <code className="text-xs text-muted-foreground">{version}</code> : null}
{!item.implemented ? (
<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-xs text-muted-foreground">
{itemSummary({ item, auth, authAccount })}
</p>
</div>
<div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
{item.implemented ? (
<Switch checked={enabled} disabled aria-label={`${item.label} availability`} />
) : null}
</div>
</div>
</div>
</div>
);
}
function SourceControlSectionSkeleton({
title,
headerAction,
}: {
readonly title: string;
readonly headerAction?: ReactNode;
}) {
return (
<SettingsSection title={title} headerAction={headerAction}>
{Array.from({ length: 2 }, (_, index) => (
<div key={index} className="border-t border-border/60 px-4 py-3.5 first:border-t-0 sm:px-5">
<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">
<Skeleton className="size-2 rounded-full" />
<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="Detected tools">
<Empty className="min-h-88">
<EmptyMedia variant="icon">
<GitPullRequestIcon />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>
{hasError ? "Could not scan source control" : "No source control tools found"}
</EmptyTitle>
<EmptyDescription>
{hasError ? error : "Install a supported Git or pull request CLI, then scan again."}
</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 discovery = useSourceControlDiscovery();
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 = () => {
void refreshSourceControlDiscovery();
};
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="Scan source control tools"
>
<RefreshCwIcon className={cn("size-3", discovery.isPending && "animate-spin")} />
</Button>
}
/>
<TooltipPopup side="top">Scan source control tools</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} />
))}
</SettingsSection>
) : null}
{result.sourceControlProviders.length > 0 ? (
<SettingsSection
title="Source Control Providers"
headerAction={result.versionControlSystems.length === 0 ? scanButton : null}
>
{result.sourceControlProviders.map((item) => (
<DiscoveryItemRow key={`provider:${item.kind}`} item={item} />
))}
</SettingsSection>
) : null}
</>
) : (
<EmptySourceControlDiscovery
error={discovery.error}
isPending={discovery.isPending}
onScan={handleScan}
/>
)}
</SettingsPageContainer>
);
}