1import { SettingsGroup } from "./SettingsGroup";
2import { AuthSettingsWriteScope } from "@t3tools/contracts";
3import { usePrimaryEnvironmentId, usePrimaryEnvironment } from "../../state/environments";
4import { useEnvironmentScope, useEnvironmentsWithScope } from "../../state/session";
5import { InfoIcon, Undo2Icon } from "lucide-react";
6import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts";
7import * as Equal from "effect/Equal";
8import { useLocation, useNavigate } from "@tanstack/react-router";
9import {
10 createContext,
11 type ComponentPropsWithoutRef,
12 type ReactNode,
13 useCallback,
14 useContext,
15 useEffect,
16 useMemo,
17 useState,
18} from "react";
19
20import {
21 PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE,
22 usePrimarySettingsAvailable,
23} from "../../hooks/useSettings";
24import { cn } from "../../lib/utils";
25import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer";
26import { Button } from "../ui/button";
27import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
28import { useOptionalSettingsScope } from "./SettingsScopeContext";
29import { SettingsScopeSentence } from "./SettingsScopeSentence";
30import {
31 isProjectScopedSettingKey,
32 listProjectOverrides,
33 type ProjectOverrideEntry,
34 scopedSettingsAreMixed,
35 scopedSettingsSource,
36} from "./scopedSettings";
37import { useClearProjectOverrides, useClearScopedSettings } from "./useScopedSettings";
38import {
39 SettingInheritance,
40 type SettingInheritanceState,
41 type SettingOverridingProject,
42} from "./SettingInheritance";
43
44const EMPTY_SETTING_KEYS: readonly (keyof ServerSettings)[] = [];
45
46declare module "@tanstack/react-router" {
47 interface HistoryState {
48 settingsTargetHighlight?: boolean;
49 }
50}
51
52interface SettingsSearchTargetContextValue {
53 readonly targetId: string | null;
54 readonly highlightTarget: boolean;
55 readonly onTargetHandled: () => void;
56}
57
58const noop = () => undefined;
59const SettingsSearchTargetContext = createContext<SettingsSearchTargetContextValue>({
60 targetId: null,
61 highlightTarget: true,
62 onTargetHandled: noop,
63});
64
65function SettingsSearchTargetProvider({
66 targetId,
67 highlightTarget = true,
68 onTargetHandled = noop,
69 children,
70}: {
71 targetId: string | null;
72 highlightTarget?: boolean;
73 onTargetHandled?: () => void;
74 children: ReactNode;
75}) {
76 const value = useMemo(
77 () => ({ targetId, highlightTarget, onTargetHandled }),
78 [highlightTarget, onTargetHandled, targetId],
79 );
80 return <SettingsSearchTargetContext value={value}>{children}</SettingsSearchTargetContext>;
81}
82
83function scrollAndFocusSettingsTarget(target: HTMLElement, highlight = true): void {
84 const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
85 const markedScrollTarget =
86 typeof target.querySelector === "function"
87 ? target.querySelector<HTMLElement>(":scope > [data-settings-scroll-target]")
88 : null;
89 const scrollTarget =
90 markedScrollTarget ??
91 (target.tagName === "SECTION" && target.firstElementChild
92 ? (target.firstElementChild as HTMLElement)
93 : target);
94
95 scrollTarget.scrollIntoView({
96 behavior: prefersReducedMotion ? "auto" : "smooth",
97 block: "center",
98 });
99 target.focus({ preventScroll: true });
100 target.classList.remove("settings-search-target-pulse");
101 if (!highlight || prefersReducedMotion) return;
102 void target.offsetWidth;
103 target.classList.add("settings-search-target-pulse");
104 // The class also suppresses the focus outline (the pulse is the destination
105 // indicator), so drop it once the element is no longer the destination.
106 target.addEventListener("blur", () => target.classList.remove("settings-search-target-pulse"), {
107 once: true,
108 });
109}
110
111/** The row id a settings-search jump is currently trying to reach, if any. */
112export function useSettingsSearchTargetId(): string | null {
113 return useContext(SettingsSearchTargetContext).targetId;
114}
115
116export function useSettingsSearchTarget<T extends HTMLElement>(id: string | undefined) {
117 const { targetId, highlightTarget, onTargetHandled } = useContext(SettingsSearchTargetContext);
118 const isSearchTarget = id !== undefined && id === targetId;
119 const targetRef = useCallback(
120 (target: T | null) => {
121 if (target && isSearchTarget) {
122 scrollAndFocusSettingsTarget(target, highlightTarget);
123 onTargetHandled();
124 }
125 },
126 [highlightTarget, isSearchTarget, onTargetHandled],
127 );
128
129 return targetRef;
130}
131
132export function SettingsSearchTarget({
133 children,
134 ...targetProps
135}: ComponentPropsWithoutRef<"div">) {
136 const targetRef = useSettingsSearchTarget<HTMLDivElement>(targetProps.id);
137 return (
138 <div {...targetProps} ref={targetRef} tabIndex={targetProps.id ? -1 : targetProps.tabIndex}>
139 {children}
140 </div>
141 );
142}
143
144/** Layout for the composer model/traits pickers in a settings row: drop the composer's max-width. */
145export const SETTINGS_PICKER_TRIGGER_CLASSNAME = "min-w-0 max-w-none shrink-0";
146
147/** Info affordance explaining how a setting interacts with the shared background policy. */
148export function PolicyTooltip({ children }: { readonly children: string }) {
149 return (
150 <Tooltip>
151 <TooltipTrigger
152 delay={200}
153 render={
154 <Button size="icon-micro" variant="ghost-muted" aria-label="Background policy details">
155 <InfoIcon className="size-3.5" />
156 </Button>
157 }
158 />
159 <TooltipPopup side="top">{children}</TooltipPopup>
160 </Tooltip>
161 );
162}
163
164/** Re-render every `intervalMs`; return a stable timestamp snapshot for render-time relative labels. */
165export function useRelativeTimeTick(intervalMs = 1_000) {
166 const [nowMs, setNowMs] = useState(() => Date.now());
167 useEffect(() => {
168 const id = setInterval(() => setNowMs(Date.now()), intervalMs);
169 return () => clearInterval(id);
170 }, [intervalMs]);
171 return nowMs;
172}
173
174/** Muted section headings have no descriptions; explanatory copy belongs to individual settings. */
175export function SettingsSection({
176 title,
177 hideTitle = false,
178 icon,
179 titleAction,
180 headerAction,
181 variant = "grouped",
182 children,
183 className,
184 ...sectionProps
185}: ComponentPropsWithoutRef<"section"> & {
186 title: string;
187 hideTitle?: boolean;
188 icon?: ReactNode;
189 /** Small control shown right after the title, such as an add button. */
190 titleAction?: ReactNode;
191 headerAction?: ReactNode;
192 variant?: "grouped" | "plain";
193 children: ReactNode;
194}) {
195 const targetRef = useSettingsSearchTarget<HTMLElement>(sectionProps.id);
196
197 return (
198 <section
199 {...sectionProps}
200 ref={targetRef}
201 tabIndex={sectionProps.id ? -1 : sectionProps.tabIndex}
202 className={cn(!hideTitle && "space-y-2.5", className)}
203 >
204 {hideTitle ? (
205 <h2 className="sr-only">{title}</h2>
206 ) : (
207 <div
208 data-settings-scroll-target
209 className="flex min-h-7 items-start justify-between gap-4 px-3 sm:px-4"
210 >
211 <div className="flex min-w-0 items-center gap-1">
212 <h2 className="flex min-h-7 items-center gap-2 text-sm font-normal text-foreground/70">
213 {icon}
214 {title}
215 </h2>
216 {titleAction}
217 </div>
218 <div className="flex min-h-7 min-w-7 items-center justify-end">{headerAction}</div>
219 </div>
220 )}
221 <SettingsGroup data-settings-scroll-target={hideTitle ? "" : undefined} variant={variant}>
222 {children}
223 </SettingsGroup>
224 </section>
225 );
226}
227
228export function SettingsUnavailableGroup({
229 children,
230 message,
231}: {
232 children: ReactNode;
233 message?: ReactNode;
234}) {
235 if (message === undefined) return children;
236
237 return (
238 <div className="border-border/60 bg-muted/20 py-1.5">
239 <div className="flex items-start gap-2 px-3 py-2 text-xs leading-relaxed text-muted-foreground sm:px-4">
240 <InfoIcon className="mt-0.5 size-3.5 shrink-0 text-warning" />
241 <p>{message}</p>
242 </div>
243 <div className="[&_h3]:opacity-64 [&_p]:opacity-64">{children}</div>
244 </div>
245 );
246}
247
248/**
249 * One setting. `serverScoped` marks rows whose value lives in the primary
250 * environment's settings.json; where there is no primary (the hosted app)
251 * the control goes inert with a tooltip instead of showing an editable
252 * default that would never save.
253 *
254 * Keep descriptions short enough for one line where possible. Allow wrapping
255 * for clarity or narrow screens instead of truncating or forcing no-wrap.
256 *
257 * Control sizing across settings follows three tiers so rows share a baseline:
258 * - `control` slot: `size="sm"` (Button, Select, Input, NumberField) or `icon-sm`.
259 * - Section `headerAction`s and buttons inside list items, cards, toolbars: `xs` / `icon-xs`.
260 * - Inline affordances (reset arrows, info tooltips, table-cell buttons): `icon-micro`.
261 * Dialog footers keep the app-wide default button size.
262 */
263export function SettingsRow({
264 title,
265 description,
266 status,
267 resetAction,
268 onResetOverride,
269 control,
270 serverScoped = false,
271 settingKeys = EMPTY_SETTING_KEYS,
272 mixed: mixedOverride,
273 children,
274 className,
275 ...rowProps
276}: Omit<ComponentPropsWithoutRef<"div">, "title"> & {
277 title: ReactNode;
278 description?: ReactNode;
279 status?: ReactNode;
280 resetAction?: ReactNode;
281 /** Replaces the default override clear for rows with side effects beyond the settings key. */
282 onResetOverride?: () => void;
283 control?: ReactNode;
284 serverScoped?: boolean;
285 settingKeys?: readonly (keyof ServerSettings)[];
286 mixed?: boolean;
287 children?: ReactNode;
288}) {
289 const targetRef = useSettingsSearchTarget<HTMLDivElement>(rowProps.id);
290 const primarySettingsAvailable = usePrimarySettingsAvailable();
291 const context = useOptionalSettingsScope();
292 const primaryEnvironmentId = usePrimaryEnvironmentId();
293 const primaryEnvironment = usePrimaryEnvironment();
294 const primaryCanWrite = useEnvironmentScope(primaryEnvironmentId, AuthSettingsWriteScope);
295 const writableIds = useEnvironmentsWithScope(
296 context?.connectedEnvironments ?? [],
297 AuthSettingsWriteScope,
298 );
299 const canWriteSettings = context
300 ? context.connectedEnvironments.length > 0 &&
301 context.connectedEnvironments.every((target) => writableIds.has(target.environmentId))
302 : primaryCanWrite;
303 const clearOverrides = useClearScopedSettings();
304 const clearProjectOverrides = useClearProjectOverrides();
305 const isProjectScope =
306 context !== null && (context.scope.kind === "project" || context.scope.kind === "checkout");
307 const scopedKeys = settingKeys.filter(isProjectScopedSettingKey);
308 // A project scope can only edit keys that support overrides; the rest stay
309 // visible so the user sees the inherited value, but cannot change it here.
310 const environmentWide = isProjectScope && serverScoped && scopedKeys.length === 0;
311 const mixed =
312 mixedOverride ?? (context !== null && scopedSettingsAreMixed(context.targets, settingKeys));
313 const source =
314 context && isProjectScope ? scopedSettingsSource(context.targets, scopedKeys) : null;
315 const unavailable =
316 serverScoped &&
317 (!canWriteSettings ||
318 !(context ? context.connectedEnvironments.length > 0 : primarySettingsAvailable));
319 const inheritedFrom =
320 source === "environment" && context?.scope.environmentIds.length === 1
321 ? (context.environments.find(
322 (environment) => environment.environmentId === context.scope.environmentIds[0],
323 )?.label ?? "environment")
324 : "environment";
325 const environmentSettingsById = useMemo(
326 () =>
327 new Map(
328 (context?.connectedEnvironments ?? []).flatMap((environment) =>
329 environment.serverConfig
330 ? [[environment.environmentId, environment.serverConfig.settings] as const]
331 : [],
332 ),
333 ),
334 [context?.connectedEnvironments],
335 );
336 // At environment scope, projects with their own value keep it when the
337 // environment default changes; the chain names them and can reset them.
338 const overridingProjects = useMemo((): SettingOverridingProject[] => {
339 if (context === null || isProjectScope || scopedKeys.length === 0) return [];
340 return listProjectOverrides(context.connectedEnvironments, scopedKeys).flatMap((entry) => {
341 const group = context.groups.find((candidate) =>
342 candidate.memberProjects.some(
343 (member) => member.environmentId === entry.environmentId && member.id === entry.projectId,
344 ),
345 );
346 if (!group) return [];
347 return [
348 {
349 ...entry,
350 label: group.displayName,
351 open: () =>
352 context.selectScope({
353 project: group.projectKey,
354 ...(context.search.machine ? { machine: context.search.machine } : {}),
355 }),
356 },
357 ];
358 });
359 }, [context, isProjectScope, scopedKeys]);
360 const renderedReset = unavailable ? null : isProjectScope && scopedKeys.length > 0 ? (
361 source === "project" || source === "mixed" ? (
362 <SettingResetButton
363 label={typeof title === "string" ? title : "override"}
364 tooltip="Reset to inherited value"
365 onClick={() => (onResetOverride ? onResetOverride() : clearOverrides(scopedKeys))}
366 />
367 ) : null
368 ) : (
369 resetAction
370 );
371 const inertControl = (message: string) => (
372 <Tooltip>
373 <TooltipTrigger
374 render={
375 // Focusable so keyboard users can still reach the explanation.
376 <span
377 tabIndex={0}
378 className="flex w-full items-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring @min-[32rem]/settings-row:w-auto"
379 />
380 }
381 >
382 <div
383 inert
384 className="flex w-full items-center gap-2 opacity-50 @min-[32rem]/settings-row:w-auto"
385 >
386 {control}
387 </div>
388 </TooltipTrigger>
389 <TooltipPopup side="top">{message}</TooltipPopup>
390 </Tooltip>
391 );
392 // A mixed selection keeps the real control with "Mixed" as its placeholder
393 // (the multi-selection inspector convention): the popover shows who has
394 // what, and picking a value applies it to every target.
395 const renderedControl =
396 unavailable && control
397 ? inertControl(
398 !canWriteSettings
399 ? `This connection lacks permission to change settings on ${
400 context
401 ? context.connectedEnvironments
402 .filter((target) => !writableIds.has(target.environmentId))
403 .map((target) => target.label)
404 .join(", ") || "the selected environment"
405 : (primaryEnvironment?.label ?? "the primary environment")
406 }.`
407 : context
408 ? "Reconnect the selected environment to change this setting."
409 : PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE,
410 )
411 : environmentWide && control
412 ? inertControl("Environment-wide setting. Select an environment to change it.")
413 : control;
414 // Server rows get an indicator beside the title that opens the resolution
415 // chain per target at every scope; client rows keep a plain status only.
416 const customized =
417 context !== null &&
418 settingKeys.some((key) =>
419 context.targets.some((candidate) => {
420 const environmentSettings = environmentSettingsById.get(candidate.environmentId);
421 return (
422 environmentSettings !== undefined &&
423 !Equal.equals(environmentSettings[key], DEFAULT_SERVER_SETTINGS[key])
424 );
425 }),
426 );
427 const inheritance: { state: SettingInheritanceState; summary: string } = mixed
428 ? { state: "mixed", summary: "Mixed across selected environments" }
429 : source === "project"
430 ? { state: "overridden", summary: "Overridden for this project" }
431 : source === "t3.json"
432 ? { state: "inherited", summary: "Inherited from the repository's t3.json" }
433 : source === "environment" && scopedKeys.length > 0
434 ? { state: "inherited", summary: `Inherited from ${inheritedFrom}` }
435 : customized
436 ? { state: "environment", summary: "Set on the environment" }
437 : { state: "default", summary: "Built-in default" };
438 const renderedInheritance =
439 context && serverScoped && settingKeys.length > 0 ? (
440 <SettingInheritance
441 state={inheritance.state}
442 summary={inheritance.summary}
443 targets={context.targets}
444 environments={context.connectedEnvironments}
445 keys={settingKeys}
446 overridingProjects={overridingProjects}
447 {...(canWriteSettings
448 ? {
449 onClearOverrides: (entries: readonly ProjectOverrideEntry[]) =>
450 clearProjectOverrides(entries, scopedKeys),
451 }
452 : {})}
453 />
454 ) : null;
455 const renderedStatus = status;
456
457 return (
458 <div
459 {...rowProps}
460 ref={targetRef}
461 tabIndex={rowProps.id ? -1 : rowProps.tabIndex}
462 data-slot="settings-row"
463 className={cn(
464 "@container/settings-row rounded-xl px-3 sm:px-4 aria-disabled:opacity-64 aria-disabled:[&_*]:text-muted-foreground",
465 children ? "pt-3 pb-1" : "py-3",
466 className,
467 )}
468 >
469 <div className="flex flex-col gap-3 @min-[32rem]/settings-row:grid @min-[32rem]/settings-row:grid-cols-[minmax(0,1fr)_minmax(10rem,auto)] @min-[32rem]/settings-row:items-center @min-[32rem]/settings-row:gap-8">
470 <div className="min-w-0 flex-1 space-y-1">
471 <div className="flex min-h-5 items-center gap-1.5">
472 <h3 className="text-sm font-medium text-foreground">{title}</h3>
473 {renderedInheritance ? (
474 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
475 {renderedInheritance}
476 </span>
477 ) : null}
478 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
479 {renderedReset}
480 </span>
481 </div>
482 {description ? (
483 <p className="max-w-xl text-xs leading-normal text-muted-foreground/80">
484 {description}
485 </p>
486 ) : null}
487 {renderedStatus ? (
488 <div className="pt-0.5 text-xs text-muted-foreground">{renderedStatus}</div>
489 ) : null}
490 </div>
491 {renderedControl ? (
492 <div className="flex w-full min-w-0 shrink-0 items-center gap-2 @min-[32rem]/settings-row:w-auto @min-[32rem]/settings-row:justify-end">
493 {renderedControl}
494 </div>
495 ) : null}
496 </div>
497 {unavailable && children ? (
498 <div inert className="opacity-50">
499 {children}
500 </div>
501 ) : (
502 children
503 )}
504 </div>
505 );
506}
507
508export function SettingResetButton({
509 label,
510 tooltip = "Reset to default",
511 disabled = false,
512 onClick,
513}: {
514 label: string;
515 tooltip?: string;
516 disabled?: boolean;
517 onClick: () => void;
518}) {
519 return (
520 <Tooltip>
521 <TooltipTrigger
522 render={
523 <Button
524 size="icon-micro"
525 variant="ghost-muted"
526 aria-label={`Reset ${label} to default`}
527 disabled={disabled}
528 onClick={(event) => {
529 event.stopPropagation();
530 onClick();
531 }}
532 >
533 <Undo2Icon className="size-3" />
534 </Button>
535 }
536 />
537 <TooltipPopup side="top">{tooltip}</TooltipPopup>
538 </Tooltip>
539 );
540}
541
542export function SettingsPageContainer({
543 children,
544 className,
545 width = "readable",
546}: {
547 children: ReactNode;
548 className?: string;
549 width?: WorkspacePageWidth;
550}) {
551 const navigate = useNavigate();
552 const hash = useLocation({ select: (location) => location.hash });
553 const highlightTarget = useLocation({
554 select: (location) => location.state.settingsTargetHighlight !== false,
555 });
556 const targetId = hash.replace(/^#/, "") || null;
557 const clearTargetHash = useCallback(() => {
558 void navigate({
559 hash: "",
560 replace: true,
561 resetScroll: false,
562 hashScrollIntoView: false,
563 state: { settingsTargetHighlight: true },
564 });
565 }, [navigate]);
566
567 return (
568 <SettingsSearchTargetProvider
569 targetId={targetId}
570 highlightTarget={highlightTarget}
571 onTargetHandled={clearTargetHash}
572 >
573 <div
574 className="topbar-scroll-fade scrollbar-gutter-both flex-1 overflow-y-auto"
575 data-settings-page-scroll
576 data-scroll-restoration-id="settings-page"
577 >
578 <WorkspacePageContainer width={width} className={cn("gap-8", className)}>
579 <SettingsScopeSentence />
580 {children}
581 </WorkspacePageContainer>
582 </div>
583 </SettingsSearchTargetProvider>
584 );
585}
586
587export function scrollToSettingsTarget(
588 targetId: string,
589 { highlight = true }: { readonly highlight?: boolean } = {},
590): boolean {
591 const target = document.getElementById(targetId);
592 if (!target) return false;
593 scrollAndFocusSettingsTarget(target, highlight);
594 return true;
595}