Skip to content
Source
Sign inSign up
Open History to see the last commit

SettingsScopeSentence.tsx

389 lines12.5 KB

1import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
2import { fileBasename } from "@t3tools/shared/path";
3import { useLocation } from "@tanstack/react-router";
4import { CheckIcon, ChevronDownIcon, FolderIcon, LayersIcon } from "lucide-react";
5import { Fragment, useState, type ReactNode } from "react";
6​
7import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping";
8import { useEnvironments, type EnvironmentPresentation } from "../../state/environments";
9import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
10import { ProjectFavicon } from "../ProjectFavicon";
11import { Button } from "../ui/button";
12import {
13 Combobox,
14 ComboboxEmpty,
15 ComboboxItem,
16 ComboboxList,
17 ComboboxPopup,
18 ComboboxSearchInput,
19 ComboboxTrigger,
20} from "../ui/combobox";
21import {
22 Menu,
23 MenuPopup,
24 MenuRadioGroup,
25 MenuRadioItem,
26 MenuRadioItemIndicator,
27 MenuSeparator,
28 MenuTrigger,
29} from "../ui/menu";
30import { useOptionalSettingsScope } from "./SettingsScopeContext";
31import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope";
32import {
33 ALL_CHECKOUTS_VALUE,
34 ALL_ENVIRONMENTS_VALUE,
35 ALL_PROJECTS_VALUE,
36 environmentAxisValue,
37 projectAxisValue,
38 projectScopeMenuGroups,
39 selectCheckoutAxis,
40 selectEnvironmentAxis,
41 selectProjectAxis,
42 settingsScopeCheckoutChoices,
43 settingsScopeCheckoutLabel,
44 settingsScopeEnvironmentAddress,
45 settingsScopeEnvironmentLabel,
46 settingsScopeEnvironmentLabels,
47 settingsScopeProjectMachines,
48} from "./settingsScopeAxis";
49​
50export const SETTINGS_DEVICE_ONLY_PATHS: ReadonlySet<string> = new Set([
51 "/settings/general",
52 "/settings/appearance",
53 "/settings/chat",
54 "/settings/diffs",
55 "/settings/snap-shot",
56 "/settings/connections",
57 // Access picks its environment on the page itself.
58 "/settings/access",
59 "/settings/about",
60]);
61​
62interface SettingsScopeMenuProps {
63 readonly value: SettingsScopeSearch;
64 readonly groups: readonly SidebarProjectSnapshot[];
65 readonly environments: readonly EnvironmentPresentation[];
66 readonly singleEnvironment: boolean;
67 readonly onChange: (next: SettingsScopeSearch) => void;
69​
70export function SettingsScopeSentence() {
71 const scope = useOptionalSettingsScope();
72 const pathname = useLocation({ select: (location) => location.pathname });
73 const { environments } = useEnvironments();
74 if (scope === null || SETTINGS_DEVICE_ONLY_PATHS.has(pathname)) return null;
75 const props: SettingsScopeMenuProps = {
76 value: scope.search,
77 singleEnvironment: scope.singleEnvironment,
78 groups: scope.groups,
79 environments,
80 onChange: scope.selectScope,
81 };
82 return (
83 <p className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 px-3 text-sm text-muted-foreground sm:px-4">
84 <span className="flex min-w-0 items-center gap-1.5">
85 <span className="shrink-0">Applying settings for</span>
86 <ProjectScopeMenu {...props} />
87 </span>
88 <span className="flex min-w-0 items-center gap-1.5">
89 <span className="shrink-0">
90 {scope.search.machine || scope.scope.kind === "checkout" ? "on" : "across"}
91 </span>
92 <EnvironmentScopeMenu {...props} />
93 </span>
94 <CheckoutScopeMenu {...props} />
95 </p>
96 );
98​
99interface ScopeOption {
100 value: string;
101 label: string;
102 secondary?: string;
103 icon?: ReactNode;
104 offline?: boolean;
106​
107function ScopeOptionLabel({ option }: { option: ScopeOption }) {
108 return (
109 <>
110 {option.icon}
111 <span className="min-w-0 flex-1">
112 <span className="block truncate">{option.label}</span>
113 {option.secondary ? (
114 <span className="block truncate text-xs text-muted-foreground">{option.secondary}</span>
115 ) : null}
116 </span>
117 {option.offline ? (
118 <span className="shrink-0 text-xs text-muted-foreground">Offline</span>
119 ) : null}
120 </>
121 );
123​
124function ScopeMenu({
125 ariaLabel,
126 icon,
127 label,
128 value,
129 options,
130 allOption,
131 searchLabel,
132 onChange,
133}: {
134 ariaLabel: string;
135 icon: ReactNode;
136 label: string;
137 value: string;
138 options: ScopeOption[];
139 allOption: ScopeOption | null;
140 searchLabel: string;
141 onChange: (next: string) => void;
142}) {
143 const [query, setQuery] = useState("");
144 const Trigger = options.length > 6 ? ComboboxTrigger : MenuTrigger;
145 const trigger = (
146 <Trigger
147 aria-label={`${ariaLabel}: ${label}`}
148 render={<Button variant="outline" size="sm" />}
149 className="min-w-0 max-w-72"
150 >
151 {icon}
152 <span className="min-w-0 truncate">{label}</span>
153 <ChevronDownIcon
154 aria-hidden
155 strokeWidth={1.75}
156 className="-me-0.5 size-3.5 shrink-0 text-muted-foreground opacity-70"
157 />
158 </Trigger>
159 );
160 const choices = allOption ? [allOption, ...options] : options;
161 if (options.length > 6) {
162 const needle = query.trim().toLowerCase();
163 const filteredItems = choices.filter((option) =>
164 `${option.label} ${option.secondary ?? ""} ${option.value}`.toLowerCase().includes(needle),
165 );
166 return (
167 <Combobox
168 items={choices}
169 filteredItems={filteredItems}
170 filter={null}
171 autoHighlight
172 itemToStringLabel={(option) => option.label}
173 isItemEqualToValue={(a, b) => a.value === b.value}
174 value={choices.find((option) => option.value === value) ?? null}
175 onOpenChange={(open) => {
176 if (!open) setQuery("");
177 }}
178 onValueChange={(option) => {
179 if (option) onChange(option.value);
180 }}
181 >
182 {trigger}
183 <ComboboxPopup align="start" className="w-80">
184 <ComboboxSearchInput
185 aria-label={searchLabel}
186 placeholder={searchLabel}
187 value={query}
188 onChange={(event) => setQuery(event.target.value)}
189 />
190 <ComboboxEmpty>No matches found.</ComboboxEmpty>
191 <ComboboxList>
192 {(option: ScopeOption) => (
193 <ComboboxItem key={option.value} value={option}>
194 <ScopeOptionLabel option={option} />
195 {option.value === value ? (
196 <CheckIcon aria-hidden className="size-3.5 shrink-0" />
197 ) : null}
198 </ComboboxItem>
199 )}
200 </ComboboxList>
201 </ComboboxPopup>
202 </Combobox>
203 );
204 }
205 return (
206 <Menu>
207 {trigger}
208 <MenuPopup align="start">
209 <div className="w-72 max-w-full">
210 <MenuRadioGroup
211 value={value}
212 onValueChange={(next) => {
213 if (typeof next === "string") onChange(next);
214 }}
215 >
216 {choices.map((option, index) => (
217 <Fragment key={option.value}>
218 <MenuRadioItem value={option.value} closeOnClick>
219 <span className="flex min-w-0 items-center gap-2">
220 <ScopeOptionLabel option={option} />
221 <MenuRadioItemIndicator />
222 </span>
223 </MenuRadioItem>
224 {index === 0 && allOption ? <MenuSeparator /> : null}
225 </Fragment>
226 ))}
227 </MenuRadioGroup>
228 </div>
229 </MenuPopup>
230 </Menu>
231 );
233​
234function EnvironmentScopeMenu({
235 value,
236 groups,
237 environments,
238 onChange,
239 singleEnvironment,
240}: SettingsScopeMenuProps) {
241 const resolved = resolveSettingsScope(value, groups, environments);
242 const environmentValue = environmentAxisValue(
243 value,
244 resolved.kind === "checkout" ? resolved.environmentId : null,
245 );
246 const selected = environments.find(
247 (environment) => environment.environmentId === environmentValue,
248 );
249 const project = groups.find((group) => group.projectKey === value.project);
250 const memberEnvironmentIds = project
251 ? new Set(project.memberProjects.map((member) => member.environmentId))
252 : null;
253 const options = memberEnvironmentIds
254 ? environments.filter((environment) => memberEnvironmentIds.has(environment.environmentId))
255 : environments;
256 return (
257 <ScopeMenu
258 ariaLabel="Environment scope"
259 icon={
260 selected ? (
261 <EnvironmentMachineIcon
262 aria-hidden
263 kind={resolveEnvironmentMachineKind(selected.serverConfig)}
264 className="size-3.5 shrink-0"
265 />
266 ) : null
267 }
268 label={
269 selected
270 ? settingsScopeEnvironmentLabel(selected, environments)
271 : environmentValue !== ALL_ENVIRONMENTS_VALUE
272 ? "Unavailable environment"
273 : singleEnvironment
274 ? "No environments"
275 : "All environments"
276 }
277 value={environmentValue}
278 onChange={(next) => onChange(selectEnvironmentAxis(value, next))}
279 searchLabel="Search environments"
280 allOption={
281 singleEnvironment
282 ? null
283 : {
284 value: ALL_ENVIRONMENTS_VALUE,
285 label: "All environments",
286 icon: <LayersIcon aria-hidden className="size-3.5 shrink-0" />,
287 }
288 }
289 options={options.map((environment) => {
290 const { primary, secondary } = settingsScopeEnvironmentLabels(environment, environments);
291 return {
292 value: environment.environmentId,
293 label: primary,
294 secondary:
295 environments.length <= 1
296 ? ""
297 : secondary || settingsScopeEnvironmentAddress(environment),
298 icon: (
299 <EnvironmentMachineIcon
300 aria-hidden
301 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
302 className="size-3.5 shrink-0"
303 />
304 ),
305 offline: environment.connection.phase !== "connected",
306 };
307 })}
308 />
309 );
311​
312function CheckoutScopeMenu({ value, groups, onChange }: SettingsScopeMenuProps) {
313 const group = groups.find((candidate) => candidate.projectKey === value.project);
314 const checkouts = group ? settingsScopeCheckoutChoices(group, value) : [];
315 if (checkouts.length === 0) return null;
316 const selected = checkouts.find((member) => member.physicalProjectKey === value.checkout);
317 return (
318 <span className="flex min-w-0 items-center gap-1.5">
319 <span className="shrink-0">in</span>
320 <ScopeMenu
321 ariaLabel="Checkout scope"
322 icon={<FolderIcon aria-hidden className="size-3.5 shrink-0" />}
323 label={
324 selected
325 ? settingsScopeCheckoutLabel(selected, checkouts)
326 : value.checkout
327 ? "Unavailable checkout"
328 : "All checkouts"
329 }
330 value={value.checkout ?? ALL_CHECKOUTS_VALUE}
331 onChange={(next) =>
332 onChange(
333 selectCheckoutAxis(
334 value,
335 checkouts.find((member) => member.physicalProjectKey === next) ?? null,
336 ),
337 )
338 }
339 searchLabel="Search checkouts"
340 allOption={{ value: ALL_CHECKOUTS_VALUE, label: "All checkouts" }}
341 options={checkouts.map((member) => ({
342 value: member.physicalProjectKey,
343 label: fileBasename(member.workspaceRoot),
344 secondary: member.workspaceRoot,
345 }))}
346 />
347 </span>
348 );
350​
351function ProjectScopeMenu({ value, groups, environments, onChange }: SettingsScopeMenuProps) {
352 const selected = groups.find((group) => group.projectKey === value.project);
353 const projects = projectScopeMenuGroups(groups, value).map((group) => ({
354 group,
355 machines: settingsScopeProjectMachines(group, environments, true).join(", "),
356 }));
357 return (
358 <ScopeMenu
359 ariaLabel="Project scope"
360 icon={selected ? <ProjectFavicon project={selected} className="size-3.5 shrink-0" /> : null}
361 label={selected?.displayName ?? (value.project ? "Unavailable project" : "All projects")}
362 value={projectAxisValue(value)}
363 onChange={(next) => onChange(selectProjectAxis(value, next))}
364 searchLabel="Search projects"
365 allOption={{ value: ALL_PROJECTS_VALUE, label: "All projects" }}
366 options={projects.map(({ group, machines }) => {
367 const sameName = projects.filter(
368 (other) =>
369 other.group.projectKey !== group.projectKey &&
370 other.group.displayName === group.displayName,
371 );
372 const location =
373 sameName.length === 0
374 ? environments.length > 1
375 ? group.workspaceRoot
376 : ""
377 : sameName.some((other) => other.machines === machines)
378 ? `${machines} · ${group.workspaceRoot}`
379 : machines;
380 return {
381 value: group.projectKey,
382 label: group.displayName,
383 secondary: group.archived ? ["Archived", location].filter(Boolean).join(" · ") : location,
384 icon: <ProjectFavicon project={group} className="size-3.5 shrink-0" />,
385 };
386 })}
387 />
388 );