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, 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 { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
19import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
20import type { ProjectOverrideEntry, ScopedSettingsTarget } from "./scopedSettings";
21import { isProjectScopedSettingKey } from "./scopedSettings";
22
23interface InheritanceLayer {
24 readonly key: "project" | "environment" | "t3.json" | "built-in";
25 readonly label: string;
26 readonly value: string;
27 readonly effective: boolean;
28 readonly set: boolean;
29}
30
31const WRITING_STYLE_LABELS: Record<string, string> = {
32 repo_conventions: "Repository conventions",
33 conventional_commits: "Conventional Commits",
34 custom: "Custom instructions",
35};
36
37/** Human labels for the values the chain can show; falls back to a type summary. */
38function formatValue(key: keyof ServerSettings, value: unknown): string {
39 if (value === null || value === undefined) {
40 return key === "pullRequestMergeMethod"
41 ? "Last selected"
42 : key === "sidebarAutoSettleAfterDays"
43 ? "Never"
44 : key === "defaultModelSelection"
45 ? "Automatic"
46 : key === "sourceControlWriterModelSelection"
47 ? "Text generation model"
48 : key === "reviewGuideModelSelection"
49 ? "Source control writer model"
50 : key === "defaultThreadEnvMode" || key === "worktreeSubmodules"
51 ? "Inherit"
52 : "Not set";
53 }
54 if (typeof value === "boolean") return value ? "On" : "Off";
55 if (typeof value === "number") {
56 return key === "sidebarAutoSettleAfterDays"
57 ? `${value} ${value === 1 ? "day" : "days"}`
58 : String(value);
59 }
60 if (typeof value === "string") {
61 if (key === "defaultThreadEnvMode" && (value === "local" || value === "worktree")) {
62 return resolveEnvModeLabel(value);
63 }
64 if (key === "worktreeSubmodules" && value in WORKTREE_SUBMODULES_LABELS) {
65 return WORKTREE_SUBMODULES_LABELS[value as WorktreeSubmodules];
66 }
67 if (key === "pullRequestMergeMethod" && value in PULL_REQUEST_MERGE_METHOD_LABELS) {
68 return PULL_REQUEST_MERGE_METHOD_LABELS[
69 value as keyof typeof PULL_REQUEST_MERGE_METHOD_LABELS
70 ];
71 }
72 return value === "" ? "Empty" : value;
73 }
74 if (Array.isArray(value)) return `${value.length} ${value.length === 1 ? "item" : "items"}`;
75 if (typeof value === "object") {
76 if ("model" in value && typeof value.model === "string") return value.model;
77 if ("mode" in value && typeof value.mode === "string") {
78 return WRITING_STYLE_LABELS[value.mode] ?? value.mode;
79 }
80 }
81 return "Custom";
82}
83
84/**
85 * The layers a setting resolves through for one target, top-down: the
86 * project override when the target is a project, the environment's value,
87 * the checkout's t3.json for file-backed keys, and the built-in default. The
88 * first layer that is set wins. Same order as `resolveProjectSettings`.
89 */
90export function settingInheritanceLayers(
91 target: ScopedSettingsTarget,
92 environmentSettings: ServerSettings,
93 key: keyof ServerSettings,
94): readonly InheritanceLayer[] {
95 const environmentValue = environmentSettings[key];
96 const source = isProjectScopedSettingKey(key) ? target.sources[key] : "environment";
97 const environmentSet = !Equal.equals(environmentValue, DEFAULT_SERVER_SETTINGS[key]);
98 const fileBacked = isProjectFileBackedSettingKey(key);
99 const layers: InheritanceLayer[] = [];
100 if (target.projectId !== null && isProjectScopedSettingKey(key)) {
101 layers.push({
102 key: "project",
103 label: "Project",
104 value: source === "project" ? formatValue(key, target.settings[key]) : "Inherits",
105 effective: source === "project",
106 set: source === "project",
107 });
108 }
109 layers.push({
110 key: "environment",
111 label: target.label,
112 value: environmentSet ? formatValue(key, environmentValue) : "Inherits",
113 effective: source === "environment" && environmentSet,
114 set: environmentSet,
115 });
116 if (fileBacked && target.projectId !== null) {
117 layers.push({
118 key: "t3.json",
119 label: "t3.json",
120 value: source === "t3.json" ? formatValue(key, target.settings[key]) : "Inherits",
121 effective: source === "t3.json",
122 set: source === "t3.json",
123 });
124 }
125 // For a file-backed key the built-in is what the resolver produced with
126 // nothing set, not the null the schema decodes to.
127 const builtIn = fileBacked
128 ? PROJECT_FILE_BACKED_SETTINGS[key].builtIn
129 : DEFAULT_SERVER_SETTINGS[key];
130 layers.push({
131 key: "built-in",
132 label: "Default",
133 value: formatValue(key, builtIn),
134 effective: source === "environment" && !environmentSet,
135 set: true,
136 });
137 return layers;
138}
139
140function isProjectFileBackedSettingKey(
141 key: keyof ServerSettings,
142): key is ProjectFileBackedSettingKey {
143 return Object.hasOwn(PROJECT_FILE_BACKED_SETTINGS, key);
144}
145
146export type SettingInheritanceState =
147 | "default"
148 | "environment"
149 | "inherited"
150 | "overridden"
151 | "mixed";
152
153/**
154 * A small indicator beside a row's title that opens a top-down view of where
155 * the setting's value comes from on each selected target. It sits inline so
156 * narrowing to a project does not add a caption line to every row.
157 */
158export interface SettingOverridingProject extends ProjectOverrideEntry {
159 readonly label: string;
160 /** Jumps the breadcrumb to this project so its override can be edited. */
161 readonly open: () => void;
162}
163
164export function SettingInheritance({
165 state,
166 summary,
167 targets,
168 environments,
169 keys,
170 overridingProjects = [],
171 onClearOverrides,
172}: {
173 state: SettingInheritanceState;
174 summary: string;
175 targets: readonly ScopedSettingsTarget[];
176 environments: readonly Pick<EnvironmentPresentation, "environmentId" | "serverConfig">[];
177 keys: readonly (keyof ServerSettings)[];
178 /** At environment scope: projects whose own value hides the environment's. */
179 overridingProjects?: readonly SettingOverridingProject[];
180 onClearOverrides?: (entries: readonly ProjectOverrideEntry[]) => void;
181}) {
182 const key = keys[0];
183 if (!key || targets.length === 0) return null;
184 const overrideSummary =
185 overridingProjects.length > 0
186 ? `${summary} · ${overridingProjects.length} project ${overridingProjects.length === 1 ? "override" : "overrides"}`
187 : summary;
188 const chains = targets.flatMap((target) => {
189 const environment = environments.find(
190 (candidate) => candidate.environmentId === target.environmentId,
191 );
192 if (!environment?.serverConfig) return [];
193 return [
194 {
195 target,
196 environment: { ...environment, serverConfig: environment.serverConfig },
197 machine: resolveEnvironmentMachineKind(environment.serverConfig),
198 layers: settingInheritanceLayers(target, environment.serverConfig.settings, key),
199 },
200 ];
201 });
202 return (
203 <Popover>
204 <Tooltip>
205 <TooltipTrigger
206 render={
207 <PopoverTrigger
208 render={
209 <Button
210 size="icon-micro"
211 variant="ghost-muted"
212 aria-label={`${overrideSummary}. Show where this value comes from`}
213 />
214 }
215 />
216 }
217 >
218 <LayersIcon
219 className={cn(
220 "size-3",
221 state === "overridden" && "text-primary",
222 state === "mixed" && "text-warning",
223 )}
224 />
225 </TooltipTrigger>
226 <TooltipPopup side="top">{overrideSummary}</TooltipPopup>
227 </Tooltip>
228 <PopoverPopup align="start" width="md" padding="none">
229 <div className="divide-y divide-border/60">
230 {chains.map(({ target, environment, machine, layers }) => (
231 <section
232 key={`${target.environmentId}:${target.projectId ?? ""}`}
233 className="px-3 py-2.5"
234 >
235 <h4 className="flex items-center gap-1.5 pb-1.5 text-xs font-medium text-muted-foreground">
236 <EnvironmentMachineIcon aria-hidden kind={machine} className="size-3.5 shrink-0" />
237 <span className="min-w-0 truncate">{target.label}</span>
238 </h4>
239 <ol role="list" className="text-sm">
240 {layers.map((layer) => (
241 <li
242 key={layer.key}
243 className={cn(
244 "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 rounded-md px-2 py-1",
245 layer.effective && "bg-foreground/[0.06]",
246 )}
247 >
248 <span
249 className={cn(
250 "min-w-0 truncate",
251 layer.effective ? "font-medium text-foreground" : "text-muted-foreground",
252 )}
253 >
254 {layer.key === "environment" ? "Environment" : layer.label}
255 </span>
256 <span
257 className={cn(
258 "flex items-center gap-1.5 tabular-nums",
259 layer.effective
260 ? "text-foreground"
261 : layer.set
262 ? "text-muted-foreground"
263 : "text-muted-foreground/60",
264 )}
265 >
266 <span className="max-w-32 truncate">{layer.value}</span>
267 {layer.effective ? (
268 <CheckIcon aria-hidden className="size-3.5 shrink-0 text-primary" />
269 ) : (
270 <span aria-hidden className="size-3.5 shrink-0" />
271 )}
272 </span>
273 </li>
274 ))}
275 </ol>
276 {(() => {
277 const overriding = overridingProjects.filter(
278 (project) => project.environmentId === target.environmentId,
279 );
280 if (overriding.length === 0) return null;
281 const overrides = environment.serverConfig.settings.projectSettingsOverrides;
282 return (
283 <div className="mt-2 border-t border-border/60 pt-2">
284 <div className="flex items-center justify-between gap-3 px-2 text-xs text-muted-foreground">
285 <span>Overridden by</span>
286 {onClearOverrides ? (
287 <InlineButton onClick={() => onClearOverrides(overriding)}>
288 Reset {overriding.length === 1 ? "it" : "all"}
289 </InlineButton>
290 ) : null}
291 </div>
292 <ul role="list" className="mt-0.5 text-sm">
293 {overriding.map((project) => (
294 <li
295 key={project.projectId}
296 className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 px-2 py-1"
297 >
298 <InlineButton className="min-w-0 justify-start" onClick={project.open}>
299 <span className="truncate">{project.label}</span>
300 </InlineButton>
301 <span className="max-w-32 truncate text-muted-foreground tabular-nums">
302 {isProjectScopedSettingKey(key)
303 ? formatValue(key, overrides[project.projectId]?.[key])
304 : null}
305 </span>
306 </li>
307 ))}
308 </ul>
309 </div>
310 );
311 })()}
312 </section>
313 ))}
314 </div>
315 </PopoverPopup>
316 </Popover>
317 );
318}