1import { useAtomValue } from "@effect/atom-react";
2import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
3import {
4 gitHubRoutingConnectionKey,
5 gitHubRoutingPermissionFor,
6 type GitHubRoutingPermission,
7} from "@t3tools/client-runtime/connection";
8import { useState } from "react";
9
10import { environmentCatalog } from "~/connection/catalog";
11import type { EnvironmentPresentation } from "~/state/environments";
12import { useAtomCommand } from "~/state/use-atom-command";
13import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
14import { toastManager } from "../ui/toast";
15import { EnvironmentRow, environmentTransportLabel } from "./EnvironmentRow";
16import { FoldedSettingsSection } from "./FoldedSettingsSection";
17import { searchableSetting } from "./settingsSearch";
18
19const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }> = [
20 { value: "off", label: "Off" },
21 { value: "read", label: "Read PRs" },
22 { value: "read-write", label: "Read and act" },
23];
24
25const summaryLabels = { "read-write": "read and act", read: "read PRs" } as const;
26
27/**
28 * Closed-header summary: the machines that share, grouped by permission.
29 * Null when nothing is shared.
30 */
31export function summarizeGitHubRouting(
32 entries: ReadonlyArray<{ readonly label: string; readonly permission: GitHubRoutingPermission }>,
33): string | null {
34 const groups = (["read-write", "read"] as const).flatMap((permission) => {
35 const labels = entries.filter((entry) => entry.permission === permission);
36 return labels.length === 0
37 ? []
38 : [`${labels.map((entry) => entry.label).join(", ")} ${summaryLabels[permission]}`];
39 });
40 return groups.length === 0 ? null : groups.join(" · ");
41}
42
43/**
44 * Folded section under the environments list. One row per switched-on machine
45 * with how much of its GitHub access the other machines may use. The trust
46 * warning is the first line of the body so it sits next to the control.
47 * Rendered only when two or more machines are on.
48 */
49export function GitHubRoutingSettings({
50 environments,
51}: {
52 readonly environments: ReadonlyArray<EnvironmentPresentation>;
53}) {
54 const permissions = useAtomValue(environmentCatalog.githubRoutingPermissionsValueAtom);
55 const catalog = useAtomValue(environmentCatalog.catalogValueAtom);
56 const update = useAtomCommand(environmentCatalog.setGitHubRoutingPermission);
57 const [saving, setSaving] = useState(false);
58
59 if (environments.length < 2) return null;
60
61 const { id, title } = searchableSetting("github-routing");
62 return (
63 <FoldedSettingsSection
64 id={id}
65 title={title}
66 summary={
67 summarizeGitHubRouting(
68 environments.map((environment) => ({
69 label: environment.label,
70 permission: gitHubRoutingPermissionFor(environment.entry, permissions),
71 })),
72 ) ?? "Off"
73 }
74 >
75 <p className="px-3 py-2.5 text-xs text-muted-foreground sm:px-4">
76 Machines you trust here can read PR data through each other's GitHub access. Enable both
77 machines. Read and act may use broader permissions than the machine that owns them. This
78 applies only to this device.
79 </p>
80 {environments.map((environment) => (
81 <EnvironmentRow
82 key={environment.environmentId}
83 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
84 label={environment.label}
85 subtitle={environmentTransportLabel(environment)}
86 >
87 <Select
88 items={options}
89 value={gitHubRoutingPermissionFor(environment.entry, permissions)}
90 disabled={
91 !catalog.isReady || saving || gitHubRoutingConnectionKey(environment.entry) === null
92 }
93 onValueChange={(permission) => {
94 if (permission === null) return;
95 setSaving(true);
96 void update({ environmentId: environment.environmentId, permission }).then(
97 (result) => {
98 setSaving(false);
99 if (result._tag === "Failure")
100 toastManager.add({
101 type: "error",
102 title: "Could not save GitHub routing permission",
103 });
104 },
105 );
106 }}
107 >
108 <SelectTrigger
109 size="xs"
110 className="w-32"
111 aria-label={`${environment.label} GitHub routing`}
112 >
113 <SelectValue />
114 </SelectTrigger>
115 <SelectPopup align="end" alignItemWithTrigger={false}>
116 {options.map(({ value, label }) => (
117 <SelectItem key={value} value={value}>
118 {label}
119 </SelectItem>
120 ))}
121 </SelectPopup>
122 </Select>
123 </EnvironmentRow>
124 ))}
125 </FoldedSettingsSection>
126 );
127}