1import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { ChevronDownIcon } from "lucide-react";
3import * as Duration from "effect/Duration";
4import * as Option from "effect/Option";
5import { useEffect, useState, type ReactNode } from "react";
6import type {
7 BackgroundActivitySettings,
8 SourceControlDiscoveryResult,
9 SourceControlProviderAuth,
10 SourceControlProviderDiscoveryItem,
11 VcsDriverKind,
12 VcsDiscoveryItem,
13} from "@t3tools/contracts";
14import {
15 getBackgroundActivityBaseProfile,
16 getBackgroundActivityPresetSettings,
17 resolveServerBackgroundActivitySettings,
18} from "@t3tools/shared/backgroundActivitySettings";
19
20import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";
21import { useSettingsScope } from "./SettingsScopeContext";
22import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
23import { cn } from "../../lib/utils";
24import { useEnvironmentQuery } from "../../state/query";
25import { sourceControlEnvironment } from "../../state/sourceControl";
26import { Badge } from "../ui/badge";
27import { Button } from "../ui/button";
28import { Collapsible, CollapsibleContent } from "../ui/collapsible";
29import {
30 Empty,
31 EmptyContent,
32 EmptyDescription,
33 EmptyHeader,
34 EmptyMedia,
35 EmptyTitle,
36} from "../ui/empty";
37import { Skeleton } from "../ui/skeleton";
38import {
39 NumberField,
40 NumberFieldDecrement,
41 NumberFieldGroup,
42 NumberFieldIncrement,
43 NumberFieldInput,
44} from "../ui/number-field";
45import { Switch } from "../ui/switch";
46import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
47
48import { GitIcon, JujutsuIcon, type Icon } from "../Icons";
49import { SourceControlHostSettings } from "./SourceControlHostSettings";
50import { GitHubAccountSettings } from "./GitHubAccountSettings";
51import { GitHubTokenSettings } from "./GitHubTokenSettings";
52import { RedactedSensitiveText } from "./RedactedSensitiveText";
53import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings";
54import {
55 PolicyTooltip,
56 SettingResetButton,
57 SettingsPageContainer,
58 SettingsSearchTarget,
59 SettingsSection,
60 useSettingsSearchTargetId,
61} from "./settingsLayout";
62import { searchableSetting, sourceControlHostSettingsSearchId } from "./settingsSearch";
63import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
64import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
65import { sourceControlIcon } from "~/sourceControlPresentation";
66
67const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
68 versionControlSystems: [],
69 sourceControlProviders: [],
70};
71
72const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = {
73 git: GitIcon,
74 jj: JujutsuIcon,
75};
76
77const SOURCE_CONTROL_SKELETON_ROWS = ["primary", "secondary"] as const;
78const GIT_FETCH_INTERVAL_STEP_SECONDS = 5;
79type BackgroundActivityOverridePatch = Partial<{
80 [K in keyof BackgroundActivitySettings["overrides"]]:
81 | BackgroundActivitySettings["overrides"][K]
82 | undefined;
83}>;
84
85function durationToSeconds(duration: Duration.Duration): number {
86 return Math.round(Duration.toMillis(duration) / 1_000);
87}
88
89function 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
96function 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
119function optionLabel(value: Option.Option<string>): string | null {
120 return Option.getOrNull(value);
121}
122
123function isProviderDiscoveryItem(
124 item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem,
125): item is SourceControlProviderDiscoveryItem {
126 return "auth" in item;
127}
128
129function isVcsNotReady(item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem): boolean {
130 return !isProviderDiscoveryItem(item) && !item.implemented;
131}
132
133function 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
146function 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
157function 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
164function 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
193function 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
260function 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
349function 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
429function 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
467function 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
505export 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}