1import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
2import { useLocation } from "@tanstack/react-router";
3import { ChevronDownIcon, LayersIcon } from "lucide-react";
4import type { ReactNode } from "react";
5
6import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping";
7import { useEnvironments, type EnvironmentPresentation } from "../../state/environments";
8import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
9import { ProjectFavicon } from "../ProjectFavicon";
10import { InlineButton } from "../ui/button";
11import {
12 Menu,
13 MenuPopup,
14 MenuRadioGroup,
15 MenuRadioItem,
16 MenuRadioItemIndicator,
17 MenuSeparator,
18 MenuTrigger,
19} from "../ui/menu";
20import { useOptionalSettingsScope } from "./SettingsScopeContext";
21import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope";
22import {
23 ALL_ENVIRONMENTS_VALUE,
24 ALL_PROJECTS_VALUE,
25 environmentAxisValue,
26 projectAxisValue,
27 selectEnvironmentAxis,
28 selectProjectAxis,
29 settingsScopeEnvironmentLabel,
30} from "./settingsScopeAxis";
31
32/** Pages whose every row is saved on this client; they have no scope to pick. */
33export const SETTINGS_DEVICE_ONLY_PATHS: ReadonlySet<string> = new Set([
34 "/settings/appearance",
35 "/settings/snap-shot",
36 "/settings/connections",
37]);
38
39interface SettingsScopeMenuProps {
40 readonly value: SettingsScopeSearch;
41 readonly groups: readonly SidebarProjectSnapshot[];
42 readonly environments: readonly EnvironmentPresentation[];
43 readonly singleEnvironment: boolean;
44 readonly onChange: (next: SettingsScopeSearch) => void;
45}
46
47/**
48 * "Applying settings for <project> across <environment>" at the top of a settings
49 * page. The two pickers are the targets a change is written to. A project is
50 * the same project on every environment, so the environment alone decides
51 * where a project override is written.
52 */
53export function SettingsScopeSentence() {
54 const scope = useOptionalSettingsScope();
55 const pathname = useLocation({ select: (location) => location.pathname });
56 const { environments } = useEnvironments();
57 if (scope === null || SETTINGS_DEVICE_ONLY_PATHS.has(pathname)) return null;
58 const props: SettingsScopeMenuProps = {
59 value: scope.search,
60 singleEnvironment: scope.singleEnvironment,
61 groups: scope.groups,
62 environments,
63 onChange: scope.selectScope,
64 };
65 return (
66 <p className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 px-3 text-base text-muted-foreground sm:px-4">
67 {/* Each connective stays with its picker so a wrap never strands "on". */}
68 <span className="flex min-w-0 items-center gap-1.5">
69 <span className="shrink-0">Applying settings for</span>
70 <ProjectScopeMenu {...props} />
71 </span>
72 <span className="flex min-w-0 items-center gap-1.5">
73 <span className="shrink-0">
74 {/* A legacy checkout link names one environment without `machine`. */}
75 {scope.search.machine || scope.scope.kind === "checkout" ? "on" : "across"}
76 </span>
77 <EnvironmentScopeMenu {...props} />
78 </span>
79 </p>
80 );
81}
82
83function ScopeMenu({
84 ariaLabel,
85 icon,
86 label,
87 children,
88}: {
89 ariaLabel: string;
90 icon: ReactNode;
91 label: string;
92 children: ReactNode;
93}) {
94 return (
95 <Menu>
96 <MenuTrigger
97 aria-label={`${ariaLabel}: ${label}`}
98 render={<InlineButton tone="picker" />}
99 className="min-w-0 max-w-72"
100 >
101 {icon}
102 <span className="min-w-0 truncate">{label}</span>
103 <ChevronDownIcon aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />
104 </MenuTrigger>
105 <MenuPopup align="start">{children}</MenuPopup>
106 </Menu>
107 );
108}
109
110function EnvironmentScopeMenu({
111 value,
112 groups,
113 environments,
114 onChange,
115 singleEnvironment,
116}: SettingsScopeMenuProps) {
117 const resolved = resolveSettingsScope(value, groups, environments);
118 const environmentValue = environmentAxisValue(
119 value,
120 resolved.kind === "checkout" ? resolved.environmentId : null,
121 );
122 const selected = environments.find(
123 (environment) => environment.environmentId === environmentValue,
124 );
125 return (
126 <ScopeMenu
127 ariaLabel="Environment scope"
128 icon={
129 selected ? (
130 <EnvironmentMachineIcon
131 aria-hidden
132 kind={resolveEnvironmentMachineKind(selected.serverConfig)}
133 className="size-3.5 shrink-0"
134 />
135 ) : null
136 }
137 label={
138 selected
139 ? settingsScopeEnvironmentLabel(selected, environments)
140 : environmentValue !== ALL_ENVIRONMENTS_VALUE
141 ? "Unavailable environment"
142 : singleEnvironment
143 ? "No environments"
144 : "All environments"
145 }
146 >
147 <MenuRadioGroup
148 value={environmentValue}
149 onValueChange={(next) => {
150 if (typeof next === "string") onChange(selectEnvironmentAxis(value, next));
151 }}
152 >
153 {!singleEnvironment ? (
154 <>
155 <MenuRadioItem value={ALL_ENVIRONMENTS_VALUE} closeOnClick>
156 <span className="flex min-w-0 items-center gap-2">
157 <LayersIcon aria-hidden className="size-3.5" />
158 <span className="min-w-0 flex-1 truncate">All environments</span>
159 <MenuRadioItemIndicator />
160 </span>
161 </MenuRadioItem>
162 <MenuSeparator />
163 </>
164 ) : null}
165 {environments.map((environment) => (
166 <MenuRadioItem
167 key={environment.environmentId}
168 value={environment.environmentId}
169 closeOnClick
170 >
171 <span className="flex min-w-0 items-center gap-2">
172 <EnvironmentMachineIcon
173 aria-hidden
174 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
175 className="size-3.5"
176 />
177 <span className="min-w-0 flex-1 truncate">
178 {settingsScopeEnvironmentLabel(environment, environments)}
179 </span>
180 {environment.connection.phase === "connected" ? null : (
181 <span className="shrink-0 text-xs text-muted-foreground">Offline</span>
182 )}
183 <MenuRadioItemIndicator />
184 </span>
185 </MenuRadioItem>
186 ))}
187 </MenuRadioGroup>
188 </ScopeMenu>
189 );
190}
191
192function ProjectScopeMenu({ value, groups, onChange }: SettingsScopeMenuProps) {
193 const selected = groups.find((group) => group.projectKey === value.project);
194 return (
195 <ScopeMenu
196 ariaLabel="Project scope"
197 icon={selected ? <ProjectFavicon project={selected} className="size-3.5 shrink-0" /> : null}
198 label={selected?.displayName ?? (value.project ? "Unavailable project" : "All projects")}
199 >
200 <MenuRadioGroup
201 value={projectAxisValue(value)}
202 onValueChange={(next) => {
203 if (typeof next === "string") onChange(selectProjectAxis(value, next));
204 }}
205 >
206 <MenuRadioItem value={ALL_PROJECTS_VALUE} closeOnClick>
207 <span className="flex min-w-0 items-center gap-2">
208 <span className="min-w-0 flex-1 truncate">All projects</span>
209 <MenuRadioItemIndicator />
210 </span>
211 </MenuRadioItem>
212 <MenuSeparator />
213 {groups.map((group) => (
214 <MenuRadioItem key={group.projectKey} value={group.projectKey} closeOnClick>
215 <span className="flex min-w-0 items-center gap-2">
216 <ProjectFavicon project={group} className="size-3.5" />
217 <span className="min-w-0 flex-1 truncate">{group.displayName}</span>
218 <MenuRadioItemIndicator />
219 </span>
220 </MenuRadioItem>
221 ))}
222 </MenuRadioGroup>
223 </ScopeMenu>
224 );
225}