518 lines · 17.0 KB · TypeScript
RawDownload
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.