1import {
2 ClientSettingsSchema,
3 type ClientSettingsPatch,
4 type EnvironmentId,
5 isNullableProjectSettingsOverride,
6 PROJECT_SCOPED_SERVER_SETTING_KEYS,
7 type ProjectId,
8 type ProjectScopedServerSettingKey,
9 type ProjectSettingsOverrides,
10 ServerSettings,
11 type T3ProjectFile,
12 type ServerSettingsPatch,
13} from "@t3tools/contracts";
14import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
15import {
16 clearProjectSettingsOverrides,
17 resolveProjectSettings,
18 resolveWorktreeCleanup,
19 type ProjectSettingSource,
20} from "@t3tools/shared/projectSettings";
21import * as Equal from "effect/Equal";
22import * as Cause from "effect/Cause";
23
24import type { ResolvedSettingsScope } from "./settingsScope";
25
26export type ScopedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch;
27
28interface ScopedSettingsEnvironment {
29 readonly environmentId: EnvironmentId;
30 readonly label: string;
31 readonly connection: { readonly phase: EnvironmentConnectionPhase };
32 readonly serverConfig: {
33 readonly settings: ServerSettings;
34 readonly environment?: {
35 readonly capabilities: { readonly projectSettingsOverrides?: boolean | undefined };
36 };
37 } | null;
38}
39
40const SERVER_KEYS = new Set<string>(Object.keys(ServerSettings.fields));
41const CLIENT_KEYS = new Set<string>(Object.keys(ClientSettingsSchema.fields));
42const PROJECT_SCOPED_KEYS = new Set<string>(PROJECT_SCOPED_SERVER_SETTING_KEYS);
43
44export function isProjectScopedSettingKey(key: string): key is ProjectScopedServerSettingKey {
45 return PROJECT_SCOPED_KEYS.has(key);
46}
47
48function isPlainObject(value: unknown): value is Record<string, unknown> {
49 return typeof value === "object" && value !== null && !Array.isArray(value);
50}
51
52/** The representative supplies display values, never the set of write targets. */
53export function selectScopedSettingsEnvironments<T extends ScopedSettingsEnvironment>(
54 scope: ResolvedSettingsScope,
55 available: readonly T[],
56 primaryEnvironmentId: EnvironmentId | null,
57) {
58 const selectedIds = new Set(scope.environmentIds);
59 const environments = available.filter((environment) =>
60 selectedIds.has(environment.environmentId),
61 );
62 const connectedEnvironments = environments.filter(
63 (environment) =>
64 environment.connection.phase === "connected" && environment.serverConfig !== null,
65 );
66 const environment =
67 connectedEnvironments.find((candidate) => candidate.environmentId === primaryEnvironmentId) ??
68 connectedEnvironments[0] ??
69 null;
70 return { environments, connectedEnvironments, environment };
71}
72
73/**
74 * One (environment, project) pair the scope writes to, with that project's
75 * effective settings. Environment scopes have no member and read the
76 * environment settings directly.
77 */
78export interface ScopedSettingsTarget {
79 readonly environmentId: EnvironmentId;
80 /** The environment's label; a project is the same project on every environment. */
81 readonly label: string;
82 readonly projectId: ProjectId | null;
83 readonly settings: ServerSettings;
84 readonly sources: Readonly<Record<ProjectScopedServerSettingKey, ProjectSettingSource>>;
85}
86
87/** Effective settings per connected target: members at project scope, environments otherwise. */
88export function resolveScopedSettingsTargets(
89 scope: ResolvedSettingsScope,
90 connectedEnvironments: readonly ScopedSettingsEnvironment[],
91 // Each member's decoded t3.json, keyed by physical project key, once read.
92 // A member absent here has no file tier yet; null is a missing or invalid file.
93 projectFiles?: ReadonlyMap<string, T3ProjectFile | null>,
94): readonly ScopedSettingsTarget[] {
95 const byId = new Map(
96 connectedEnvironments.map((environment) => [environment.environmentId, environment]),
97 );
98 if (scope.kind === "project" || scope.kind === "checkout") {
99 return scope.members.flatMap((member) => {
100 const environment = byId.get(member.environmentId);
101 if (!environment?.serverConfig) return [];
102 const projectFile = projectFiles?.get(member.physicalProjectKey);
103 const resolved =
104 projectFile === undefined
105 ? resolveProjectSettings(environment.serverConfig.settings, member.id)
106 : resolveProjectSettings(environment.serverConfig.settings, member.id, null, projectFile);
107 return [
108 {
109 environmentId: member.environmentId,
110 label: environment.label,
111 projectId: member.id,
112 settings: resolved.settings,
113 sources: resolved.sources,
114 },
115 ];
116 });
117 }
118 return connectedEnvironments.flatMap((environment) =>
119 environment.serverConfig
120 ? [
121 {
122 environmentId: environment.environmentId,
123 label: environment.label,
124 projectId: null,
125 settings: environment.serverConfig.settings,
126 sources: resolveProjectSettings(environment.serverConfig.settings, null).sources,
127 },
128 ]
129 : [],
130 );
131}
132
133export function scopedSettingsAreMixed(
134 targets: readonly Pick<ScopedSettingsTarget, "settings">[],
135 keys: readonly (keyof ServerSettings)[],
136): boolean {
137 const first = targets[0];
138 return (
139 first !== undefined &&
140 targets.some((candidate) =>
141 keys.some((key) => !Equal.equals(first.settings[key], candidate.settings[key])),
142 )
143 );
144}
145
146export type ScopedSettingSource = ProjectSettingSource | "mixed";
147
148/** Whether the keys are overridden on every target, inherited on every target, or split. */
149export function scopedSettingsSource(
150 targets: readonly Pick<ScopedSettingsTarget, "sources">[],
151 keys: readonly (keyof ServerSettings)[],
152): ScopedSettingSource {
153 const scoped = keys.filter(isProjectScopedSettingKey);
154 if (scoped.length === 0 || targets.length === 0) return "environment";
155 const sources = new Set(targets.flatMap((target) => scoped.map((key) => target.sources[key])));
156 return sources.size > 1
157 ? "mixed"
158 : sources.has("project")
159 ? "project"
160 : sources.has("t3.json")
161 ? "t3.json"
162 : "environment";
163}
164
165interface ScopedServerWrite {
166 readonly environmentId: EnvironmentId;
167 readonly label: string;
168 readonly patch: ServerSettingsPatch;
169 /** The environment's settings before the write, to tell a dropped key from a saved one. */
170 readonly previous: ServerSettings;
171}
172
173function projectOverrideWrites(
174 scope: Extract<ResolvedSettingsScope, { kind: "project" | "checkout" }>,
175 environments: readonly ScopedSettingsEnvironment[],
176 update: (
177 current: ProjectSettingsOverrides,
178 settings: ServerSettings,
179 projectId: ProjectId,
180 ) => ProjectSettingsOverrides | null,
181): ScopedServerWrite[] {
182 const byId = new Map(environments.map((environment) => [environment.environmentId, environment]));
183 const writes = new Map<EnvironmentId, ScopedServerWrite>();
184 for (const member of scope.members) {
185 const environment = byId.get(member.environmentId);
186 if (
187 !environment?.serverConfig ||
188 environment.connection.phase !== "connected" ||
189 environment.serverConfig.environment?.capabilities.projectSettingsOverrides !== true
190 ) {
191 continue;
192 }
193 const settings = environment.serverConfig.settings;
194 const entry = update(settings.projectSettingsOverrides[member.id] ?? {}, settings, member.id);
195 const existing = writes.get(member.environmentId);
196 writes.set(member.environmentId, {
197 environmentId: member.environmentId,
198 label: environment.label,
199 previous: settings,
200 patch: {
201 projectSettingsOverrides: {
202 ...existing?.patch.projectSettingsOverrides,
203 [member.id]: entry,
204 },
205 },
206 });
207 }
208 return [...writes.values()];
209}
210
211/**
212 * Environment scopes write the patch to every connected environment; project
213 * and checkout scopes write the scopable keys into each member's override
214 * entry on its environment. Client keys always persist locally.
215 */
216export function planScopedSettingsPatch(
217 scope: ResolvedSettingsScope,
218 environments: readonly ScopedSettingsEnvironment[],
219 patch: ScopedSettingsPatch,
220) {
221 const clientPatch = Object.fromEntries(
222 Object.entries(patch).filter(([key]) => CLIENT_KEYS.has(key) && !SERVER_KEYS.has(key)),
223 ) as ClientSettingsPatch;
224 const serverPatch = Object.fromEntries(
225 Object.entries(patch).filter(([key]) => SERVER_KEYS.has(key)),
226 ) as ServerSettingsPatch;
227 const serverKeys = Object.keys(serverPatch);
228 const { connectedEnvironments } = selectScopedSettingsEnvironments(scope, environments, null);
229 const isProjectScope = scope.kind === "project" || scope.kind === "checkout";
230 const unscopableKeys = isProjectScope
231 ? serverKeys.filter((key) => !isProjectScopedSettingKey(key))
232 : [];
233 const serverWrites: ScopedServerWrite[] =
234 serverKeys.length === 0
235 ? []
236 : isProjectScope
237 ? unscopableKeys.length > 0
238 ? []
239 : projectOverrideWrites(scope, environments, (current, settings, projectId) => {
240 // Object-valued keys arrive as partial patches (the writing style
241 // rows send one field); an override entry stores the whole value,
242 // so complete the patch from the target's effective value.
243 const effective = resolveProjectSettings(settings, projectId).settings;
244 const next: Record<string, unknown> = { ...current };
245 for (const [key, value] of Object.entries(serverPatch)) {
246 if (key === "worktreeCleanup" && serverPatch.worktreeCleanup?.mode === "custom") {
247 next[key] = {
248 mode: "custom",
249 rules: {
250 ...resolveWorktreeCleanup(settings, projectId),
251 ...serverPatch.worktreeCleanup.rules,
252 },
253 };
254 continue;
255 }
256 // A picker's "Inherit" sends null; for keys whose override
257 // cannot store null that means remove the override.
258 if (
259 value === null &&
260 !isNullableProjectSettingsOverride(key as ProjectScopedServerSettingKey)
261 ) {
262 delete next[key];
263 continue;
264 }
265 const base = effective[key as keyof ServerSettings];
266 next[key] =
267 isPlainObject(value) && isPlainObject(base) ? { ...base, ...value } : value;
268 }
269 return next as ProjectSettingsOverrides;
270 })
271 : scope.kind === "all" || scope.kind === "environment"
272 ? connectedEnvironments.flatMap((environment) => {
273 const previous = environment.serverConfig?.settings;
274 if (!previous) return [];
275 return [
276 {
277 environmentId: environment.environmentId,
278 label: environment.label,
279 previous,
280 patch:
281 previous.worktreeCleanup != null &&
282 serverPatch.storageCleanup &&
283 Object.keys(serverPatch.storageCleanup).some((key) =>
284 key.startsWith("worktree"),
285 )
286 ? {
287 ...serverPatch,
288 worktreeCleanup: {
289 mode: "custom",
290 rules: {
291 ...resolveWorktreeCleanup(previous, null),
292 ...Object.fromEntries(
293 Object.entries(serverPatch.storageCleanup).filter(([key]) =>
294 key.startsWith("worktree"),
295 ),
296 ),
297 },
298 },
299 }
300 : serverPatch,
301 },
302 ];
303 })
304 : [];
305 const hasClientWrite = Object.keys(clientPatch).length > 0;
306 const hasWrite = hasClientWrite || serverWrites.length > 0;
307 const unavailableReason =
308 hasWrite || Object.keys(patch).length === 0
309 ? null
310 : scope.kind === "unavailable"
311 ? scope.message
312 : unscopableKeys.length > 0
313 ? "This setting is environment-wide and cannot be overridden by a project."
314 : isProjectScope
315 ? "Connect the selected checkouts, or update their environments, to save a project override."
316 : `Connect ${scope.kind === "environment" ? scope.label : "an environment"} to save this setting.`;
317 return { clientPatch, hasClientWrite, serverWrites, unavailableReason };
318}
319
320/** Remove the keys' project overrides so each member inherits its environment value again. */
321export function planScopedSettingsClear(
322 scope: ResolvedSettingsScope,
323 environments: readonly ScopedSettingsEnvironment[],
324 keys: readonly ProjectScopedServerSettingKey[],
325) {
326 const serverWrites =
327 scope.kind === "project" || scope.kind === "checkout"
328 ? projectOverrideWrites(scope, environments, (_current, settings, projectId) =>
329 clearProjectSettingsOverrides(settings, projectId, keys),
330 )
331 : [];
332 return {
333 clientPatch: {} as ClientSettingsPatch,
334 hasClientWrite: false,
335 serverWrites,
336 unavailableReason:
337 serverWrites.length > 0
338 ? null
339 : "Connect the selected checkouts, or update their environments, to reset this override.",
340 };
341}
342
343export interface ProjectOverrideEntry {
344 readonly environmentId: EnvironmentId;
345 readonly projectId: ProjectId;
346}
347
348/**
349 * The projects on the selected environments that override `keys`. An
350 * environment edit leaves these untouched, so the row can name them and
351 * offer to clear them.
352 */
353export function listProjectOverrides(
354 environments: readonly ScopedSettingsEnvironment[],
355 keys: readonly (keyof ServerSettings)[],
356): readonly ProjectOverrideEntry[] {
357 const scoped = keys.filter(isProjectScopedSettingKey);
358 if (scoped.length === 0) return [];
359 return environments.flatMap((environment) => {
360 const overrides = environment.serverConfig?.settings.projectSettingsOverrides;
361 if (!overrides) return [];
362 return Object.entries(overrides).flatMap(([projectId, entry]) =>
363 scoped.some((key) => Object.hasOwn(entry, key))
364 ? [{ environmentId: environment.environmentId, projectId: projectId as ProjectId }]
365 : [],
366 );
367 });
368}
369
370/** Drop `keys` from the named project entries so they follow the environment again. */
371export function planProjectOverridesClear(
372 environments: readonly ScopedSettingsEnvironment[],
373 entries: readonly ProjectOverrideEntry[],
374 keys: readonly ProjectScopedServerSettingKey[],
375) {
376 const byId = new Map(environments.map((environment) => [environment.environmentId, environment]));
377 const writes = new Map<EnvironmentId, ScopedServerWrite>();
378 for (const { environmentId, projectId } of entries) {
379 const environment = byId.get(environmentId);
380 if (!environment?.serverConfig || environment.connection.phase !== "connected") continue;
381 const settings = environment.serverConfig.settings;
382 const existing = writes.get(environmentId);
383 writes.set(environmentId, {
384 environmentId,
385 label: environment.label,
386 previous: settings,
387 patch: {
388 projectSettingsOverrides: {
389 ...existing?.patch.projectSettingsOverrides,
390 [projectId]: clearProjectSettingsOverrides(settings, projectId, keys),
391 },
392 },
393 });
394 }
395 const serverWrites = [...writes.values()];
396 return {
397 clientPatch: {} as ClientSettingsPatch,
398 hasClientWrite: false,
399 serverWrites,
400 unavailableReason:
401 serverWrites.length > 0 ? null : "Connect the environments to reset these overrides.",
402 };
403}
404
405/**
406 * Whether a server kept `before` where `value` asked for a change. Objects are
407 * compared field by field, so a nested key an older server dropped is caught
408 * even when a sibling field saved. A null clears the field; strings may come
409 * back trimmed.
410 */
411function writeWasDropped(value: unknown, before: unknown, saved: unknown): boolean {
412 if (value === null) return before != null && Equal.equals(saved, before);
413 if (isPlainObject(value) && !Array.isArray(before) && !Array.isArray(saved)) {
414 const field = (source: unknown, key: string) =>
415 isPlainObject(source) ? source[key] : undefined;
416 return Object.entries(value).some(([key, next]) =>
417 writeWasDropped(next, field(before, key), field(saved, key)),
418 );
419 }
420 const unchanged =
421 typeof value === "string" && typeof before === "string"
422 ? value.trim() === before
423 : Equal.equals(value, before);
424 return !unchanged && Equal.equals(saved, before);
425}
426
427/**
428 * An older server drops keys it does not know, top-level or nested, and still
429 * succeeds. Left unreported, the row stays mixed forever.
430 */
431function droppedSettingKeys(write: ScopedServerWrite, saved: ServerSettings | undefined) {
432 if (saved === undefined) return [];
433 return Object.entries(write.patch).flatMap(([key, value]) =>
434 writeWasDropped(
435 value,
436 write.previous[key as keyof ServerSettings],
437 saved[key as keyof ServerSettings],
438 )
439 ? [key]
440 : [],
441 );
442}
443
444/** Wait for every target so a failed environment does not hide successful or later writes. */
445export async function persistScopedSettingsPatch(
446 plan: ReturnType<typeof planScopedSettingsPatch>,
447 persistServer: (input: {
448 environmentId: EnvironmentId;
449 input: { patch: ServerSettingsPatch };
450 }) => Promise<{
451 readonly _tag: "Success" | "Failure";
452 readonly cause?: Cause.Cause<unknown>;
453 readonly value?: ServerSettings;
454 }>,
455 persistClient: (patch: ClientSettingsPatch) => void,
456) {
457 if (plan.hasClientWrite) persistClient(plan.clientPatch);
458 const results = await Promise.allSettled(
459 plan.serverWrites.map(({ environmentId, patch }) =>
460 persistServer({ environmentId, input: { patch } }),
461 ),
462 );
463 const saved = (index: number) => {
464 const result = results[index];
465 return (
466 result?.status === "fulfilled" &&
467 result.value._tag === "Success" &&
468 droppedSettingKeys(plan.serverWrites[index]!, result.value.value).length === 0
469 );
470 };
471 const failedEnvironments = plan.serverWrites.flatMap((environment, index) => {
472 if (saved(index)) return [];
473 const result = results[index];
474 if (result?.status === "fulfilled" && result.value._tag === "Success") {
475 return [
476 {
477 ...environment,
478 message: "This environment does not support this setting. Update The Knife on it.",
479 },
480 ];
481 }
482 const error: unknown =
483 result?.status === "rejected"
484 ? result.reason
485 : result?.status === "fulfilled" && result.value.cause
486 ? Cause.squash(result.value.cause)
487 : undefined;
488 return [
489 {
490 ...environment,
491 message:
492 error instanceof Error
493 ? error.message
494 : "The save failed. Try reconnecting and saving again.",
495 },
496 ];
497 });
498 const savedEnvironments = plan.serverWrites.filter((_, index) => saved(index));
499 return {
500 failedEnvironments,
501 savedEnvironments,
502 savedEnvironmentCount: savedEnvironments.length,
503 };
504}