1/**
2 * Environment-scoped settings hooks.
3 *
4 * Abstracts the split between server-authoritative settings (persisted in
5 * `settings.json` on the server, fetched via `server.getConfig`) and
6 * client-only settings (persisted in localStorage).
7 *
8 * Live server settings always require an environment id. Primary-environment
9 * access is intentionally named as such so environment-sensitive consumers
10 * cannot silently read the wrong server's settings.
11 */
12import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore, use } from "react";
13import { RegistryContext, useAtomValue } from "@effect/atom-react";
14import {
15 DEFAULT_SERVER_SETTINGS,
16 AuthSettingsWriteScope,
17 requiredScopesForServerSettingsPatch,
18 type EnvironmentId,
19 type ProviderInstanceMutation,
20 type ProviderInstanceId,
21 ServerSettings,
22 type ServerSettingsPatch,
23 sessionGrantsScope,
24} from "@t3tools/contracts";
25import {
26 type ClientSettingsPatch,
27 type ClientSettings,
28 DEFAULT_CLIENT_SETTINGS,
29 type EnvironmentIdentificationMode,
30 type UnifiedSettings,
31} from "@t3tools/contracts/settings";
32import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
33import {
34 isAtomCommandInterrupted,
35 squashAtomCommandFailure,
36} from "@t3tools/client-runtime/state/runtime";
37import {
38 filterSharedServerPatch,
39 splitSharedServerPatch,
40 supportsSharedSettingsSync,
41} from "@t3tools/client-runtime/state/shared-settings";
42import { ensureLocalApi } from "~/localApi";
43import {
44 getThemeDefinition,
45 getThemePreviewSidebarArtwork,
46 resolveThemeHalf,
47 subscribeToThemePreview,
48 themeAllowsSidebarArtwork,
49} from "~/themePalette";
50import {
51 modelPreferencesMigrationPatch,
52 resolveModelPreferences,
53} from "@t3tools/shared/serverSettings";
54import * as Struct from "effect/Struct";
55import * as Option from "effect/Option";
56import { AsyncResult, Atom } from "effect/reactivity";
57import { toastManager } from "~/components/ui/toast";
58import { isHostedStaticApp } from "~/hostedPairing";
59import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";
60import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";
61import { useAtomCommand } from "~/state/use-atom-command";
62import { useTheme } from "./useTheme";
63import { environmentSession, readEnvironmentScope, useEnvironmentScope } from "~/state/session";
64import { appAtomRegistry } from "~/rpc/atomRegistry";
65
66const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";
67
68type UnifiedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch;
69
70const clientSettingsEditListeners = new Set<(patch: ClientSettingsPatch) => void>();
71
72export function subscribeClientSettingsEdits(listener: (patch: ClientSettingsPatch) => void) {
73 clientSettingsEditListeners.add(listener);
74 return () => {
75 clientSettingsEditListeners.delete(listener);
76 };
77}
78
79const clientSettingsListeners = new Set<() => void>();
80const clientSettingsHydrationListeners = new Set<() => void>();
81type ClientSettingsHydrationStatus = "pending" | "ready" | "failed" | "retrying";
82let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
83let clientSettingsHydrationStatus: ClientSettingsHydrationStatus = "pending";
84let clientSettingsHydrationPromise: Promise<void> | null = null;
85let clientSettingsHydrationGeneration = 0;
86let clientSettingsPersistenceQueue: Promise<void> = Promise.resolve();
87let deferredClientSettingsPatchCount = 0;
88
89function emitClientSettingsChange() {
90 for (const listener of clientSettingsListeners) {
91 listener();
92 }
93}
94
95function emitClientSettingsHydrationChange() {
96 for (const listener of clientSettingsHydrationListeners) {
97 listener();
98 }
99}
100
101function getClientSettingsSnapshot(): ClientSettings {
102 return clientSettingsSnapshot;
103}
104
105function replaceClientSettingsSnapshot(settings: ClientSettings): void {
106 clientSettingsSnapshot = settings;
107 emitClientSettingsChange();
108}
109
110function setClientSettingsHydrationStatus(nextStatus: ClientSettingsHydrationStatus): void {
111 if (clientSettingsHydrationStatus === nextStatus) {
112 return;
113 }
114 clientSettingsHydrationStatus = nextStatus;
115 emitClientSettingsHydrationChange();
116}
117
118function subscribeClientSettings(listener: () => void): () => void {
119 clientSettingsListeners.add(listener);
120 void hydrateClientSettings().catch(() => undefined);
121 return () => {
122 clientSettingsListeners.delete(listener);
123 };
124}
125
126function getClientSettingsHydratedSnapshot(): boolean {
127 return clientSettingsHydrationStatus === "ready";
128}
129
130function getClientSettingsHydrationStatusSnapshot(): ClientSettingsHydrationStatus {
131 return clientSettingsHydrationStatus;
132}
133
134function subscribeClientSettingsHydration(listener: () => void): () => void {
135 clientSettingsHydrationListeners.add(listener);
136 void hydrateClientSettings().catch(() => undefined);
137 return () => {
138 clientSettingsHydrationListeners.delete(listener);
139 };
140}
141
142async function hydrateClientSettings(): Promise<void> {
143 if (clientSettingsHydrationStatus === "ready") {
144 return;
145 }
146 if (clientSettingsHydrationPromise) {
147 return clientSettingsHydrationPromise;
148 }
149
150 const hydrationGeneration = clientSettingsHydrationGeneration;
151 setClientSettingsHydrationStatus(
152 clientSettingsHydrationStatus === "failed" || clientSettingsHydrationStatus === "retrying"
153 ? "retrying"
154 : "pending",
155 );
156 const nextHydration = (async () => {
157 try {
158 const persistedSettings = await ensureLocalApi().persistence.getClientSettings();
159 if (hydrationGeneration !== clientSettingsHydrationGeneration) {
160 return;
161 }
162 if (persistedSettings) {
163 replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings });
164 }
165 setClientSettingsHydrationStatus("ready");
166 } catch (error) {
167 if (hydrationGeneration === clientSettingsHydrationGeneration) {
168 setClientSettingsHydrationStatus("failed");
169 }
170 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, {
171 operation: "hydrate",
172 ...safeErrorLogAttributes(error),
173 });
174 throw error;
175 }
176 })();
177
178 const hydrationPromise = nextHydration.finally(() => {
179 if (clientSettingsHydrationPromise === hydrationPromise) {
180 clientSettingsHydrationPromise = null;
181 }
182 });
183 clientSettingsHydrationPromise = hydrationPromise;
184
185 return clientSettingsHydrationPromise;
186}
187
188const defaultClientSettingsPersistence = (settings: ClientSettings): Promise<void> =>
189 ensureLocalApi().persistence.setClientSettings(settings);
190
191function enqueueClientSettingsPersistence<A>(work: () => Promise<A>): Promise<A> {
192 const result = clientSettingsPersistenceQueue.then(work);
193 clientSettingsPersistenceQueue = result.then(
194 () => undefined,
195 () => undefined,
196 );
197 return result;
198}
199
200export function persistClientSettingsPatch(
201 patch: ClientSettingsPatch,
202 persist: (settings: ClientSettings) => Promise<void> = defaultClientSettingsPersistence,
203 source: "local" | "sync" = "local",
204): Promise<void> {
205 if (source === "local") {
206 for (const listener of clientSettingsEditListeners) listener(patch);
207 }
208 // Patches queued before hydration must publish before newer optimistic patches.
209 const deferPatch =
210 clientSettingsHydrationStatus !== "ready" || deferredClientSettingsPatchCount > 0;
211 if (deferPatch) {
212 deferredClientSettingsPatchCount += 1;
213 } else {
214 replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch });
215 }
216 return enqueueClientSettingsPersistence(async () => {
217 if (deferPatch) {
218 try {
219 if (clientSettingsHydrationStatus !== "ready") {
220 await hydrateClientSettings();
221 }
222 replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch });
223 } finally {
224 deferredClientSettingsPatchCount -= 1;
225 }
226 }
227 await persist(getClientSettingsSnapshot());
228 }).catch((error) => {
229 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, {
230 operation: "persist",
231 ...safeErrorLogAttributes(error),
232 });
233 if (source === "sync") throw error;
234 });
235}
236
237/**
238 * Persists a client-settings update before publishing it to the in-memory
239 * snapshot. If another settings write lands while persistence is pending, the
240 * updater is reapplied to that newer snapshot and persisted again so neither
241 * change is lost.
242 */
243export async function persistClientSettingsUpdate(
244 update: (current: ClientSettings) => ClientSettings,
245 persist: (settings: ClientSettings) => Promise<void> = defaultClientSettingsPersistence,
246): Promise<ClientSettings> {
247 return enqueueClientSettingsPersistence(async () => {
248 if (clientSettingsHydrationStatus !== "ready") {
249 await hydrateClientSettings();
250 }
251 for (;;) {
252 const current = getClientSettingsSnapshot();
253 const next = update(current);
254 await persist(next);
255 if (getClientSettingsSnapshot() === current) {
256 replaceClientSettingsSnapshot(next);
257 return next;
258 }
259 }
260 });
261}
262
263// ── Key sets for routing patches ─────────────────────────────────────
264
265const SERVER_SETTINGS_KEYS = new Set<string>(Struct.keys(ServerSettings.fields));
266
267function splitPatch(patch: UnifiedSettingsPatch): {
268 serverPatch: ServerSettingsPatch;
269 clientPatch: ClientSettingsPatch;
270} {
271 const serverPatch: Record<string, unknown> = {};
272 const clientPatch: Record<string, unknown> = {};
273 for (const [key, value] of Object.entries(patch)) {
274 if (SERVER_SETTINGS_KEYS.has(key)) {
275 serverPatch[key] = value;
276 } else {
277 clientPatch[key] = value;
278 }
279 }
280 return {
281 serverPatch: serverPatch as ServerSettingsPatch,
282 clientPatch: clientPatch as ClientSettingsPatch,
283 };
284}
285
286// ── Hooks ────────────────────────────────────────────────────────────
287
288/**
289 * Non-hook accessor for the current merged client settings snapshot.
290 * Used by non-React code paths (e.g. runtime services) that need the latest
291 * settings without subscribing.
292 */
293export function getClientSettings(): ClientSettings {
294 return getClientSettingsSnapshot();
295}
296
297/**
298 * Resolves after settings load or storage confirms no saved settings exist.
299 * Failed reads reject and remain retryable. They must not allow defaults to
300 * overwrite saved preferences.
301 *
302 * The pre-hydration snapshot is just the schema defaults, so imperative paths
303 * that open a preview must await this or they bake the built-in viewport, zoom
304 * and appearance into a tab that never picks up the user's saved values.
305 */
306export function ensureClientSettingsHydrated(): Promise<void> {
307 return hydrateClientSettings();
308}
309
310export function useClientSettingsHydrated(): boolean {
311 return useSyncExternalStore(
312 subscribeClientSettingsHydration,
313 getClientSettingsHydratedSnapshot,
314 () => false,
315 );
316}
317
318export function useClientSettingsHydrationStatus(): ClientSettingsHydrationStatus {
319 return useSyncExternalStore(
320 subscribeClientSettingsHydration,
321 getClientSettingsHydrationStatusSnapshot,
322 () => "pending",
323 );
324}
325
326function useClientSettingsValue(): ClientSettings {
327 return useSyncExternalStore(
328 subscribeClientSettings,
329 getClientSettingsSnapshot,
330 () => DEFAULT_CLIENT_SETTINGS,
331 );
332}
333
334export function mergeEnvironmentSettings(
335 serverSettings: ServerSettings,
336 clientSettings: ClientSettings,
337): UnifiedSettings {
338 // Decode drops retired client keys, but older untyped persistence adapters
339 // can still return them. Server-owned values must always win.
340 return {
341 ...clientSettings,
342 ...serverSettings,
343 ...resolveModelPreferences(serverSettings, clientSettings),
344 };
345}
346
347const pendingModelPreferencesMigrations = new Set<EnvironmentId>();
348
349/** Seed saved client preferences once without overwriting another client's edits. */
350export function useMigrateModelPreferences(
351 environmentId: EnvironmentId | null,
352 settings: ServerSettings | null,
353) {
354 const clientSettings = useClientSettingsValue();
355 const hydrated = useClientSettingsHydrated();
356 const migrate = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });
357 useEffect(() => {
358 if (
359 !environmentId ||
360 !settings ||
361 !hydrated ||
362 pendingModelPreferencesMigrations.has(environmentId)
363 )
364 return;
365 const patch = modelPreferencesMigrationPatch(settings, clientSettings);
366 if (!patch) return;
367 pendingModelPreferencesMigrations.add(environmentId);
368 void migrate({ environmentId, input: { patch } }).finally(() =>
369 pendingModelPreferencesMigrations.delete(environmentId),
370 );
371 }, [clientSettings, environmentId, hydrated, migrate, settings]);
372}
373
374function useMergedSettings<T>(
375 serverSettings: ServerSettings,
376 selector: ((settings: UnifiedSettings) => T) | undefined,
377): T {
378 const clientSettings = useClientSettingsValue();
379
380 const merged = useMemo<UnifiedSettings>(
381 () => mergeEnvironmentSettings(serverSettings, clientSettings),
382 [clientSettings, serverSettings],
383 );
384
385 return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]);
386}
387
388export function useClientSettings<T = ClientSettings>(
389 selector?: (settings: ClientSettings) => T,
390): T {
391 const settings = useClientSettingsValue();
392 return useMemo(() => (selector ? selector(settings) : (settings as T)), [selector, settings]);
393}
394
395export function resolveEnvironmentIdentificationMode(input: {
396 mode: EnvironmentIdentificationMode;
397 settingsHydrated: boolean;
398 paletteThemeActive?: boolean;
399 paletteThemeAllowsArtwork?: boolean;
400}): EnvironmentIdentificationMode {
401 // Avoid briefly rendering the default artwork before a persisted pill/none choice loads.
402 if (!input.settingsHydrated) return "none";
403 // Artwork palettes are maintained for built-ins only. Keep an explicit
404 // "none", but use the theme-aware pill for user-controlled palettes.
405 return input.paletteThemeActive && !input.paletteThemeAllowsArtwork && input.mode === "artwork"
406 ? "pill"
407 : input.mode;
408}
409
410export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMode {
411 const settingsHydrated = useClientSettingsHydrated();
412 const mode = useClientSettingsValue().environmentIdentificationMode;
413 const { resolvedTheme, theme, themeHalves } = useTheme();
414 const previewSidebarArtwork = useSyncExternalStore(
415 subscribeToThemePreview,
416 getThemePreviewSidebarArtwork,
417 () => null,
418 );
419 const activeTheme = resolveThemeHalf(theme, themeHalves, resolvedTheme);
420 const activeThemeDefinition = getThemeDefinition(activeTheme);
421 return resolveEnvironmentIdentificationMode({
422 mode,
423 settingsHydrated,
424 paletteThemeActive: previewSidebarArtwork !== null || activeThemeDefinition !== null,
425 paletteThemeAllowsArtwork: previewSidebarArtwork ?? themeAllowsSidebarArtwork(activeTheme),
426 });
427}
428
429/**
430 * Whether the legacy sidebar (Settings → General → Legacy features) replaces
431 * the default one.
432 *
433 * Held at the default sidebar until client settings hydrate: the pre-hydration
434 * snapshot is just the schema defaults, so resolving against it could mount one
435 * sidebar and then swap it out once persisted settings land — remounting the
436 * whole tree for everyone instead of only for legacy opt-ins.
437 */
438export function useLegacySidebarEnabled(): boolean {
439 const settingsHydrated = useClientSettingsHydrated();
440 const legacySidebarEnabled = useClientSettingsValue().legacySidebarEnabled;
441 return settingsHydrated && legacySidebarEnabled;
442}
443
444/** Hydrate legacy preferences before a mutation can claim either server-owned list. */
445export async function prepareModelPreferencesPatch(
446 settings: ServerSettings,
447 patch: ServerSettingsPatch,
448): Promise<ServerSettingsPatch> {
449 if (settings.favorites === null || settings.providerModelPreferences === null) {
450 await ensureClientSettingsHydrated();
451 }
452 return { ...modelPreferencesMigrationPatch(settings, getClientSettings()), ...patch };
453}
454
455export function useUpdateEnvironmentModelPreferences(environmentId: EnvironmentId | null) {
456 const settings = useAtomValue(
457 environmentId ? serverEnvironment.settingsValueAtom(environmentId) : noEnvironmentSettingsAtom,
458 );
459 const save = useAtomCommand(serverEnvironment.updateSettings, "model preferences update");
460 return useCallback(
461 async (patch: ServerSettingsPatch) => {
462 if (!environmentId || !settings) return;
463 let prepared: ServerSettingsPatch;
464 try {
465 prepared = await prepareModelPreferencesPatch(settings, patch);
466 } catch {
467 toastManager.add({
468 type: "error",
469 title: "Model preferences not saved",
470 description: "Could not load saved model preferences. Try again.",
471 });
472 return;
473 }
474 return save({ environmentId, input: { patch: prepared } });
475 },
476 [environmentId, save, settings],
477 );
478}
479
480/** Send the desired state so two clients adding a favorite do not cancel each other. */
481export function useToggleEnvironmentModelFavorite(environmentId: EnvironmentId | null) {
482 const registry = use(RegistryContext);
483 const pendingFavorites = useRef(
484 new Map<string, { favorite: boolean; settings: ServerSettings; pending: boolean }>(),
485 );
486 const save = useUpdateEnvironmentModelPreferences(environmentId);
487 return useCallback(
488 async (provider: ProviderInstanceId, model: string) => {
489 if (!environmentId) return;
490 const settingsAtom = serverEnvironment.settingsValueAtom(environmentId);
491 const settings = registry.get(settingsAtom);
492 if (!settings) return;
493 if (settings.favorites === null) {
494 try {
495 await ensureClientSettingsHydrated();
496 } catch {
497 toastManager.add({
498 type: "error",
499 title: "Favorites not saved",
500 description: "Could not load saved model preferences. Try again.",
501 });
502 return;
503 }
504 }
505 const currentSettings = registry.get(settingsAtom) ?? settings;
506 const favorites = resolveModelPreferences(currentSettings, getClientSettings()).favorites;
507 const key = JSON.stringify([environmentId, provider, model]);
508 const previous = pendingFavorites.current.get(key);
509 // Keep the acknowledged choice until the config stream catches up with the RPC reply.
510 const previousFavorite =
511 previous && (previous.pending || previous.settings.favorites === currentSettings.favorites)
512 ? previous.favorite
513 : favorites.some(
514 (favorite) => favorite.provider === provider && favorite.model === model,
515 );
516 const pending = {
517 favorite: !previousFavorite,
518 settings: currentSettings,
519 pending: true,
520 };
521 pendingFavorites.current.set(key, pending);
522 try {
523 const result = await save({
524 setModelFavorites: [{ provider, model, favorite: pending.favorite }],
525 });
526 if (
527 result &&
528 AsyncResult.isSuccess(result) &&
529 registry.get(settingsAtom)?.favorites === currentSettings.favorites
530 ) {
531 pending.pending = false;
532 }
533 } finally {
534 if (pending.pending && pendingFavorites.current.get(key) === pending) {
535 if (
536 previous &&
537 !previous.pending &&
538 registry.get(settingsAtom)?.favorites === previous.settings.favorites
539 ) {
540 pendingFavorites.current.set(key, previous);
541 } else {
542 pendingFavorites.current.delete(key);
543 }
544 }
545 }
546 },
547 [environmentId, registry, save],
548 );
549}
550
551const noEnvironmentSettingsAtom = Atom.make<ServerSettings | null>(null);
552
553/** Model pickers may render before a settings target has been selected. */
554export function useEnvironmentModelPreferences(environmentId: EnvironmentId | null) {
555 const settings = useAtomValue(
556 environmentId ? serverEnvironment.settingsValueAtom(environmentId) : noEnvironmentSettingsAtom,
557 );
558 const legacy = useClientSettingsValue();
559 useMigrateModelPreferences(environmentId, settings);
560 return useMemo(
561 () => resolveModelPreferences(settings ?? DEFAULT_SERVER_SETTINGS, legacy),
562 [legacy, settings],
563 );
564}
565
566/** Read current settings for one environment, merged with client-local preferences. */
567export function useEnvironmentSettings<T = UnifiedSettings>(
568 environmentId: EnvironmentId,
569 selector?: (settings: UnifiedSettings) => T,
570): T {
571 const serverSettings = useAtomValue(serverEnvironment.settingsValueAtom(environmentId));
572 useMigrateModelPreferences(environmentId, serverSettings);
573 return useMergedSettings(serverSettings ?? DEFAULT_SERVER_SETTINGS, selector);
574}
575
576/** Atomically mutate one provider instance against the server's latest settings snapshot. */
577export function usePersistEnvironmentProviderInstanceMutation(environmentId: EnvironmentId) {
578 const mutateProviderInstance = useAtomCommand(serverEnvironment.mutateProviderInstance, {
579 reportFailure: false,
580 });
581 return useCallback(
582 (providerInstanceMutation: ProviderInstanceMutation, patch: ServerSettingsPatch = {}) =>
583 mutateProviderInstance({
584 environmentId,
585 input: { patch, providerInstanceMutation },
586 }),
587 [environmentId, mutateProviderInstance],
588 );
589}
590
591/** Primary-only settings access for the settings UI and other explicitly global surfaces. */
592export function usePrimarySettings<T = UnifiedSettings>(
593 selector?: (settings: UnifiedSettings) => T,
594): T {
595 const environment = usePrimaryEnvironment();
596 const settings = useAtomValue(primaryServerSettingsAtom);
597 useMigrateModelPreferences(
598 environment?.environmentId ?? null,
599 environment?.serverConfig?.settings ?? null,
600 );
601 return useMergedSettings(settings, selector);
602}
603
604export const PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE =
605 "This setting is saved on a server, and the hosted app is not anchored to one. Change it from the desktop app or from the server's own address.";
606
607/**
608 * Whether primary-scoped server settings have a server to live on. The
609 * hosted app connects to every environment as a remote, so it has no primary:
610 * `usePrimarySettings` reads schema defaults there and writes have nowhere
611 * to go. Desktop and server-served web always have one.
612 */
613export function usePrimarySettingsAvailable(): boolean {
614 const primaryEnvironment = usePrimaryEnvironment();
615 return primaryEnvironment !== null || !isHostedStaticApp();
616}
617
618/** Connected sync targets, excluding grants already known to forbid settings writes. */
619function useSharedSettingsSyncTargetIds(includePending = false): ReadonlyArray<EnvironmentId> {
620 const { environments } = useEnvironments();
621 const writableTargetsAtom = useMemo(
622 () =>
623 Atom.make((get) =>
624 environments.filter(supportsSharedSettingsSync).flatMap((environment) => {
625 const result = get(environmentSession.sessionStateAtom(environment.environmentId));
626 // A cold grant must not drop a shared edit. The server authorizes the
627 // write; mismatch suggestions still wait for a confirmed grant.
628 if (includePending && result._tag === "Initial") {
629 return [environment.environmentId];
630 }
631 const session =
632 result._tag === "Failure" ? null : Option.getOrNull(AsyncResult.value(result));
633 return session !== null && sessionGrantsScope(session, AuthSettingsWriteScope)
634 ? [environment.environmentId]
635 : [];
636 }),
637 ),
638 [environments, includePending],
639 );
640 return useAtomValue(writableTargetsAtom);
641}
642
643/**
644 * Returns an updater that routes each key to the correct backing store.
645 *
646 * Server keys are optimistically patched in atom-backed server state, then
647 * persisted via RPC. Shared server keys (see `SHARED_SERVER_SETTING_KEYS`)
648 * are written to every eligible sync target, not only the selected target, so
649 * a user preference does not silently drift between machines. Client keys go
650 * through client persistence.
651 */
652function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
653 // Mount this session even on pages without a visible permission-gated control.
654 useEnvironmentScope(environmentId, AuthSettingsWriteScope);
655 const persist = useAtomCommand(serverEnvironment.updateSettings, {
656 label: "server settings update",
657 reportFailure: false,
658 });
659 const { environments } = useEnvironments();
660 const persistServerSettings = useCallback(
661 async (request: Parameters<typeof persist>[0]) => {
662 const result = await persist(request);
663 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
664 const label =
665 environments.find((environment) => environment.environmentId === request.environmentId)
666 ?.label ?? request.environmentId;
667 const error = squashAtomCommandFailure(result);
668 toastManager.add({
669 type: "error",
670 title: "Setting not saved",
671 description: `Could not save on ${label}: ${error instanceof Error ? error.message : "The save failed. Try reconnecting and saving again."}`,
672 });
673 }
674 return result;
675 },
676 [environments, persist],
677 );
678 const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(true);
679 const updateSettings = useCallback(
680 (patch: UnifiedSettingsPatch) => {
681 const { serverPatch, clientPatch } = splitPatch(patch);
682
683 const canWriteServerPatch =
684 environmentId === null ||
685 requiredScopesForServerSettingsPatch(serverPatch).every((scope) =>
686 readEnvironmentScope(environmentId, scope),
687 );
688 if (Object.keys(serverPatch).length > 0 && !canWriteServerPatch) {
689 toastManager.add({
690 type: "warning",
691 title: "Setting not saved",
692 description: `This connection lacks permission to change settings on ${environments.find((target) => target.environmentId === environmentId)?.label ?? "the selected environment"}.`,
693 });
694 }
695 if (Object.keys(serverPatch).length > 0 && canWriteServerPatch) {
696 const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch);
697 // Dropping the write silently leaves the control looking saved.
698 const warnUnsaved = (description = PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE) =>
699 toastManager.add({
700 type: "warning",
701 title: "Setting not saved",
702 description,
703 });
704 if (Object.keys(localPatch).length > 0) {
705 if (environmentId) {
706 void persistServerSettings({
707 environmentId,
708 input: { patch: localPatch },
709 });
710 } else {
711 warnUnsaved();
712 }
713 }
714 if (Object.keys(sharedPatch).length > 0) {
715 const sourceSettings = environments.find(
716 (target) => target.environmentId === environmentId,
717 )?.serverConfig?.settings;
718 const targets = new Set(sharedSettingsSyncTargetIds);
719 if (environmentId) {
720 targets.add(environmentId);
721 }
722 const writes: Array<Parameters<typeof persist>[0]> = [];
723 const deniedLabels: string[] = [];
724 for (const targetId of targets) {
725 const target = environments.find((candidate) => candidate.environmentId === targetId);
726 const targetPatch = filterSharedServerPatch(
727 sharedPatch,
728 target?.serverConfig?.environment.capabilities,
729 target?.serverConfig?.settings,
730 sourceSettings,
731 targetId === environmentId,
732 );
733 if (Object.keys(targetPatch).length === 0) continue;
734 const session = appAtomRegistry.get(environmentSession.sessionStateAtom(targetId));
735 if (
736 session._tag !== "Initial" &&
737 !requiredScopesForServerSettingsPatch(sharedPatch).every((scope) =>
738 readEnvironmentScope(targetId, scope),
739 )
740 ) {
741 deniedLabels.push(target?.label ?? targetId);
742 continue;
743 }
744 writes.push({
745 environmentId: targetId,
746 input: { patch: targetPatch },
747 });
748 }
749 if (writes.length === 0) {
750 warnUnsaved(
751 deniedLabels.length > 0
752 ? `This connection lacks permission to change settings on ${deniedLabels.join(", ")}.`
753 : targets.size > 0
754 ? "Update older servers to save this setting."
755 : undefined,
756 );
757 } else {
758 void Promise.all(
759 writes.map(async (request) => ({
760 label:
761 environments.find((target) => target.environmentId === request.environmentId)
762 ?.label ?? request.environmentId,
763 result: await persist(request),
764 })),
765 ).then((outcomes) => {
766 const failures = outcomes.flatMap(({ label, result }) => {
767 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return [];
768 const error = squashAtomCommandFailure(result);
769 return [
770 `Could not save on ${label}: ${error instanceof Error ? error.message : "The save failed. Try reconnecting and saving again."}`,
771 ];
772 });
773 if (failures.length === 0) return;
774 const saved = outcomes
775 .filter(({ result }) => result._tag === "Success")
776 .map(({ label }) => label);
777 toastManager.add({
778 type: "error",
779 title:
780 saved.length > 0 ? "Setting saved on some environments" : "Setting not saved",
781 description: [
782 ...failures,
783 ...(saved.length > 0 ? [`Saved on ${saved.join(", ")}.`] : []),
784 ].join("\n"),
785 });
786 });
787 }
788 }
789 }
790 if (Object.keys(clientPatch).length > 0) {
791 void persistClientSettingsPatch(clientPatch);
792 }
793 },
794 [environmentId, environments, persist, persistServerSettings, sharedSettingsSyncTargetIds],
795 );
796
797 return updateSettings;
798}
799
800export function useUpdateEnvironmentSettings(environmentId: EnvironmentId | null) {
801 return useUpdateSettingsTarget(environmentId);
802}
803
804export function useUpdatePrimarySettings() {
805 return useUpdateSettingsTarget(usePrimaryEnvironment()?.environmentId ?? null);
806}
807
808export function useUpdateClientSettings() {
809 return useCallback((patch: ClientSettingsPatch) => {
810 return persistClientSettingsPatch(patch);
811 }, []);
812}
813
814export function __resetClientSettingsPersistenceForTests(): void {
815 clientSettingsHydrationGeneration += 1;
816 clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
817 clientSettingsHydrationStatus = "pending";
818 clientSettingsHydrationPromise = null;
819 clientSettingsPersistenceQueue = Promise.resolve();
820 deferredClientSettingsPatchCount = 0;
821 clientSettingsListeners.clear();
822 clientSettingsHydrationListeners.clear();
823}
824
825export function __setClientSettingsForTests(settings: ClientSettings): void {
826 clientSettingsHydrationGeneration += 1;
827 clientSettingsSnapshot = settings;
828 clientSettingsHydrationStatus = "ready";
829 clientSettingsHydrationPromise = null;
830}