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

SettingInheritance.tsx

472 lines18.8 KB

1import {
2 DEFAULT_SERVER_SETTINGS,
3 PROJECT_FILE_BACKED_SETTINGS,
4 type ProjectFileBackedSettingKey,
5 resolveEnvironmentMachineKind,
6 type ServerSettings,
7 type WorktreeSubmodules,
8} from "@t3tools/contracts";
9import { CheckIcon, ChevronRightIcon, LayersIcon } from "lucide-react";
10import * as Equal from "effect/Equal";
11​
12import { cn } from "../../lib/utils";
13import type { EnvironmentPresentation } from "../../state/environments";
14import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
15import { resolveEnvModeLabel, WORKTREE_SUBMODULES_LABELS } from "../BranchToolbar.logic";
16import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";
17import { Button, InlineButton } from "../ui/button";
18import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
19import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
20import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
21import { useOptionalSettingsScope } from "./SettingsScopeContext";
22import type { ProjectOverrideEntry, ScopedSettingsTarget } from "./scopedSettings";
23import { isProjectScopedSettingKey } from "./scopedSettings";
24import { settingsScopeEnvironmentLabels } from "./settingsScopeAxis";
25​
26interface InheritanceLayer {
27 readonly key: "project" | "environment" | "t3.json" | "built-in";
28 readonly label: string;
29 readonly value: string;
30 readonly effective: boolean;
31 readonly set: boolean;
33​
34const WRITING_STYLE_LABELS: Record<string, string> = {
35 repo_conventions: "Repository conventions",
36 conventional_commits: "Conventional Commits",
37 custom: "Custom instructions",
38};
39​
40const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record<string, string> = {
41 balanced: "Balanced",
42 performance: "Performance",
43 "battery-saver": "Battery saver",
44 custom: "Advanced",
45};
46​
47function formatValue(key: keyof ServerSettings, value: unknown): string {
48 if (value === null || value === undefined) {
49 return key === "pullRequestMergeMethod"
50 ? "Last selected"
51 : key === "sidebarAutoSettleAfterDays"
52 ? "Never"
53 : key === "defaultModelSelection"
54 ? "Automatic"
55 : key === "sourceControlWriterModelSelection"
56 ? "Text generation model"
57 : key === "reviewGuideModelSelection"
58 ? "Source control writer model"
59 : key === "defaultThreadEnvMode" || key === "worktreeSubmodules"
60 ? "Inherit"
61 : "Not set";
62 }
63 if (typeof value === "boolean") return value ? "On" : "Off";
64 if (typeof value === "number") {
65 return key === "sidebarAutoSettleAfterDays"
66 ? `${value} ${value === 1 ? "day" : "days"}`
67 : String(value);
68 }
69 if (typeof value === "string") {
70 if (key === "defaultThreadEnvMode" && (value === "local" || value === "worktree")) {
71 return resolveEnvModeLabel(value);
72 }
73 if (key === "worktreeSubmodules" && value in WORKTREE_SUBMODULES_LABELS) {
74 return WORKTREE_SUBMODULES_LABELS[value as WorktreeSubmodules];
75 }
76 if (key === "pullRequestMergeMethod" && value in PULL_REQUEST_MERGE_METHOD_LABELS) {
77 return PULL_REQUEST_MERGE_METHOD_LABELS[
78 value as keyof typeof PULL_REQUEST_MERGE_METHOD_LABELS
79 ];
80 }
81 return value === "" ? "Empty" : value;
82 }
83 if (Array.isArray(value)) return `${value.length} ${value.length === 1 ? "item" : "items"}`;
84 if (typeof value === "object") {
85 if (key === "backgroundActivity" && "profile" in value && typeof value.profile === "string") {
86 return BACKGROUND_ACTIVITY_PROFILE_LABELS[value.profile] ?? value.profile;
87 }
88 if ("model" in value && typeof value.model === "string") return value.model;
89 if ("mode" in value && typeof value.mode === "string") {
90 if (key === "worktreeCleanup") return value.mode === "off" ? "Off" : "Custom";
91 return key === "sourceControlWritingStyle"
92 ? (WRITING_STYLE_LABELS[value.mode] ?? value.mode)
93 : value.mode;
94 }
95 }
96 return "Custom";
98​
99/** Matches resolveProjectSettings order. */
100export function settingInheritanceLayers(
101 target: ScopedSettingsTarget,
102 environmentSettings: ServerSettings,
103 key: keyof ServerSettings,
104): readonly InheritanceLayer[] {
105 const environmentValue = environmentSettings[key];
106 const source = isProjectScopedSettingKey(key) ? target.sources[key] : "environment";
107 const environmentSet = !Equal.equals(environmentValue, DEFAULT_SERVER_SETTINGS[key]);
108 const fileBacked = isProjectFileBackedSettingKey(key);
109 const layers: InheritanceLayer[] = [];
110 if (target.projectId !== null && isProjectScopedSettingKey(key)) {
111 layers.push({
112 key: "project",
113 label: "Project",
114 value: source === "project" ? formatValue(key, target.settings[key]) : "Inherits",
115 effective: source === "project",
116 set: source === "project",
117 });
118 }
119 layers.push({
120 key: "environment",
121 label: target.label,
122 value: environmentSet ? formatValue(key, environmentValue) : "Inherits",
123 effective: source === "environment" && environmentSet,
124 set: environmentSet,
125 });
126 if (fileBacked && target.projectId !== null) {
127 layers.push({
128 key: "t3.json",
129 label: "t3.json",
130 value: source === "t3.json" ? formatValue(key, target.settings[key]) : "Inherits",
131 effective: source === "t3.json",
132 set: source === "t3.json",
133 });
134 }
135 // File-backed defaults come from the resolver, not the schema's decoded null.
136 const builtIn = fileBacked
137 ? PROJECT_FILE_BACKED_SETTINGS[key].builtIn
138 : DEFAULT_SERVER_SETTINGS[key];
139 layers.push({
140 key: "built-in",
141 label: "Default",
142 value: formatValue(key, builtIn),
143 effective: source === "environment" && !environmentSet,
144 set: true,
145 });
146 return layers;
148​
149function isProjectFileBackedSettingKey(
150 key: keyof ServerSettings,
151): key is ProjectFileBackedSettingKey {
152 return Object.hasOwn(PROJECT_FILE_BACKED_SETTINGS, key);
154​
155export type SettingInheritanceState =
156 | "default"
157 | "environment"
158 | "inherited"
159 | "overridden"
160 | "mixed";
161​
162export interface SettingOverridingProject extends ProjectOverrideEntry {
163 readonly label: string;
164 readonly open: () => void;
166​
167export function SettingInheritance({
168 state,
169 summary,
170 targets,
171 environments,
172 keys,
173 overridingProjects = [],
174 onClearOverrides,
175}: {
176 state: SettingInheritanceState;
177 summary: string;
178 targets: readonly ScopedSettingsTarget[];
179 environments: readonly Pick<
180 EnvironmentPresentation,
181 "environmentId" | "label" | "displayUrl" | "serverConfig"
182 >[];
183 keys: readonly (keyof ServerSettings)[];
184 overridingProjects?: readonly SettingOverridingProject[];
185 onClearOverrides?: (entries: readonly ProjectOverrideEntry[]) => void;
186}) {
187 const scopeContext = useOptionalSettingsScope();
188 const key = keys[0];
189 if (!key || targets.length === 0) return null;
190 const hasMultipleEnvironments = new Set(targets.map((target) => target.environmentId)).size > 1;
191 const overrideSummary =
192 overridingProjects.length > 0
193 ? `${summary} · ${overridingProjects.length} project ${overridingProjects.length === 1 ? "override" : "overrides"}`
194 : summary;
195 const chains = targets.flatMap((target) => {
196 const environment = environments.find(
197 (candidate) => candidate.environmentId === target.environmentId,
198 );
199 if (!environment?.serverConfig) return [];
200 const labels = settingsScopeEnvironmentLabels(environment, environments);
201 const path = scopeContext?.scope.members.find(
202 (member) => member.environmentId === target.environmentId && member.id === target.projectId,
203 )?.workspaceRoot;
204 const secondary = [labels.secondary, path?.split(/[\\/]/).filter(Boolean).slice(-2).join("/")]
205 .filter(Boolean)
206 .join(" · ");
207 const layers = settingInheritanceLayers(target, environment.serverConfig.settings, key);
208 const winningLayer = layers.find((layer) => layer.effective);
209 return [
210 {
211 id: `${target.environmentId}:${target.projectId ?? ""}`,
212 target,
213 label: [labels.primary, secondary].filter(Boolean).join(" · "),
214 machineLabel: [labels.primary, labels.secondary].filter(Boolean).join(" · "),
215 overrides: environment.serverConfig.settings.projectSettingsOverrides,
216 machine: resolveEnvironmentMachineKind(environment.serverConfig),
217 layers,
218 winningLayer,
219 resolution: {
220 winner: winningLayer?.key,
221 layers: layers.map(({ key, value }) => [key, value]),
222 settings: keys.map((settingKey) => target.settings[settingKey]),
223 },
224 },
225 ];
226 });
227 const primary =
228 chains.find(
229 ({ target }) =>
230 target.environmentId === scopeContext?.target?.environmentId &&
231 target.projectId === scopeContext?.target?.projectId,
232 ) ?? chains[0];
233 const machineLabelFor = (environmentId: string) =>
234 chains.find((entry) => entry.target.environmentId === environmentId)?.machineLabel ?? "";
235 const groups: (typeof chains)[] = [];
236 for (const chain of hasMultipleEnvironments && primary
237 ? [primary, ...chains.filter((entry) => entry !== primary)]
238 : chains) {
239 const group =
240 hasMultipleEnvironments &&
241 groups.find(([first]) => first && Equal.equals(first.resolution, chain.resolution));
242 if (group) group.push(chain);
243 else groups.push([chain]);
244 }
245 return (
246 <Popover>
247 <Tooltip>
248 <TooltipTrigger
249 render={
250 <PopoverTrigger
251 render={
252 <Button
253 size="icon-micro"
254 variant="ghost-muted"
255 aria-label={`${overrideSummary}. Show where this value comes from`}
256 />
257 }
258 />
259 }
260 >
261 <LayersIcon
262 className={cn(
263 "size-3",
264 state === "overridden" && "text-primary",
265 state === "mixed" && "text-warning",
266 )}
267 />
268 </TooltipTrigger>
269 <TooltipPopup side="top">{overrideSummary}</TooltipPopup>
270 </Tooltip>
271 <PopoverPopup align="start" width="md" padding="none">
272 <div className="divide-y divide-border/60">
273 {groups.map((members) => {
274 const first = members[0];
275 if (!first) return null;
276 const { id, target, machine, layers, winningLayer } = first;
277 const chain = (
278 <ol role="list" className="text-sm">
279 {layers.map((layer) => (
280 <li
281 key={layer.key}
282 className={cn(
283 "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 rounded-md px-2 py-1",
284 layer.effective && "bg-foreground/[0.06]",
285 )}
286 >
287 <span
288 className={cn(
289 "min-w-0 truncate",
290 layer.effective ? "font-medium text-foreground" : "text-muted-foreground",
291 )}
292 >
293 {layer.key === "environment" ? "Environment" : layer.label}
294 </span>
295 <span
296 className={cn(
297 "flex items-center gap-1.5 tabular-nums",
298 layer.effective
299 ? "text-foreground"
300 : layer.set
301 ? "text-muted-foreground"
302 : "text-muted-foreground/60",
303 )}
304 >
305 <span className="max-w-32 truncate">{layer.value}</span>
306 {layer.effective ? (
307 <CheckIcon aria-hidden className="size-3.5 shrink-0 text-primary" />
308 ) : (
309 <span aria-hidden className="size-3.5 shrink-0" />
310 )}
311 </span>
312 </li>
313 ))}
314 </ol>
315 );
316 return (
317 <section key={id} className="px-3 py-2.5">
318 {!hasMultipleEnvironments ? (
319 <h4 className="flex items-center gap-1.5 pb-1.5 text-xs font-medium text-muted-foreground">
320 <EnvironmentMachineIcon
321 aria-hidden
322 kind={machine}
323 className="size-3.5 shrink-0"
324 />
325 <span className="min-w-0 truncate">{target.label}</span>
326 </h4>
327 ) : null}
328 {hasMultipleEnvironments && groups.length > 1 ? (
329 <Collapsible>
330 <CollapsibleTrigger
331 render={
332 <Button
333 variant="ghost"
334 size="sm-multiline"
335 className="w-full justify-start text-left"
336 />
337 }
338 >
339 <span className="flex w-full min-w-0 flex-col">
340 <span className="flex min-w-0 items-center gap-2">
341 <span className="min-w-0 flex-1 truncate text-sm text-foreground tabular-nums">
342 {winningLayer?.value}
343 </span>
344 <span className="text-xs text-muted-foreground">
345 {winningLayer?.key === "environment"
346 ? "Environment"
347 : winningLayer?.label}
348 </span>
349 <ChevronRightIcon
350 aria-hidden
351 className="size-3 shrink-0 text-muted-foreground [[data-panel-open]_&]:rotate-90"
352 />
353 </span>
354 <span className="flex min-w-0 flex-col gap-0.5 text-xs text-muted-foreground">
355 {members.map((member) => (
356 <span
357 key={member.id}
358 className="flex min-w-0 max-w-full items-center gap-1"
359 >
360 <EnvironmentMachineIcon
361 aria-hidden
362 kind={member.machine}
363 className="size-3 shrink-0"
364 />
365 <span className="truncate">{member.label}</span>
366 </span>
367 ))}
368 </span>
369 </span>
370 </CollapsibleTrigger>
371 <CollapsiblePanel>
372 <div className="pt-1.5">{chain}</div>
373 </CollapsiblePanel>
374 </Collapsible>
375 ) : (
376 <>
377 {chain}
378 {hasMultipleEnvironments ? (
379 <p className="px-2 pt-1.5 text-xs text-muted-foreground">
380 Same on all machines
381 </p>
382 ) : null}
383 </>
384 )}
385 {(() => {
386 const overriding = overridingProjects.filter((project) =>
387 members.some((member) => member.target.environmentId === project.environmentId),
388 );
389 if (overriding.length === 0) return null;
390 const foldOverrides = hasMultipleEnvironments && overriding.length > 3;
391 const header = (
392 <div className="flex items-center justify-between gap-3 px-2 text-xs text-muted-foreground">
393 {foldOverrides ? (
394 <CollapsibleTrigger render={<InlineButton tone="muted" />}>
395 <ChevronRightIcon
396 aria-hidden
397 className="size-3 [[data-panel-open]_&]:rotate-90"
398 />
399 Overridden by
400 </CollapsibleTrigger>
401 ) : (
402 <span>Overridden by</span>
403 )}
404 {onClearOverrides ? (
405 <InlineButton onClick={() => onClearOverrides(overriding)}>
406 Reset {overriding.length === 1 ? "it" : "all"}
407 </InlineButton>
408 ) : null}
409 </div>
410 );
411 const projects = (
412 <ul
413 role="list"
414 className={cn("mt-0.5 text-sm", foldOverrides && "max-h-48 overflow-y-auto")}
415 >
416 {overriding.map((project) => (
417 <li
418 key={`${project.environmentId}:${project.projectId}`}
419 className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 px-2 py-1"
420 >
421 <span className="flex min-w-0 flex-col items-start">
422 <InlineButton
423 className="min-w-0 max-w-full justify-start"
424 onClick={project.open}
425 >
426 <span className="truncate">{project.label}</span>
427 </InlineButton>
428 {hasMultipleEnvironments ? (
429 <span className="max-w-full truncate text-xs text-muted-foreground">
430 {machineLabelFor(project.environmentId)}
431 </span>
432 ) : null}
433 </span>
434 <span className="max-w-32 truncate text-muted-foreground tabular-nums">
435 {isProjectScopedSettingKey(key)
436 ? formatValue(
437 key,
438 members.find(
439 (member) =>
440 member.target.environmentId === project.environmentId,
441 )?.overrides[project.projectId]?.[key],
442 )
443 : null}
444 </span>
445 </li>
446 ))}
447 </ul>
448 );
449 return (
450 <div className="mt-2 border-t border-border/60 pt-2">
451 {foldOverrides ? (
452 <Collapsible>
453 {header}
454 <CollapsiblePanel>{projects}</CollapsiblePanel>
455 </Collapsible>
456 ) : (
457 <>
458 {header}
459 {projects}
460 </>
461 )}
462 </div>
463 );
464 })()}
465 </section>
466 );
467 })}
468 </div>
469 </PopoverPopup>
470 </Popover>
471 );