1import { useRef, useSyncExternalStore, use } from "react";
2import { useSyncExternalStoreWithSelector } from "use-sync-external-store/with-selector";
3import { RegistryContext, useAtomValue } from "@effect/atom-react";
4import {
5 DEFAULT_SERVER_SETTINGS,
6 AuthSettingsWriteScope,
7 requiredScopesForServerSettingsPatch,
8 type EnvironmentId,
9 type ProviderInstanceMutation,
10 type ProviderInstanceId,
11 ServerSettings,
12 type ServerSettingsPatch,
13 sessionGrantsScope,
14} from "@t3tools/contracts";
15import {
16 type ClientSettingsPatch,
17 type ClientSettings,
18 DEFAULT_CLIENT_SETTINGS,
19 type EnvironmentIdentificationMode,
20 type UnifiedSettings,
21} from "@t3tools/contracts/settings";
22import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
23import {
24 isAtomCommandInterrupted,
25 runAtomCommand,
26 squashAtomCommandFailure,
27} from "@t3tools/client-runtime/state/runtime";
28import {
29 filterSharedServerPatch,
30 splitSharedServerPatch,
31 supportsSharedSettingsSync,
32} from "@t3tools/client-runtime/state/shared-settings";
33import { ensureLocalApi } from "~/localApi";
34import { subscribeBrowserClientSettingsWrites } from "~/clientPersistenceStorage";
35import {
36 getThemeDefinition,
37 getThemePreviewSidebarArtwork,
38 resolveThemeHalf,
39 subscribeToThemePreview,
40 themeAllowsSidebarArtwork,
41} from "~/themePalette";
42import {
43 modelPreferencesMigrationPatch,
44 resolveModelPreferences,
45} from "@t3tools/shared/serverSettings";
46import * as Equal from "effect/Equal";
47import * as Struct from "effect/Struct";
48import * as Option from "effect/Option";
49import { AsyncResult, Atom } from "effect/reactivity";
50import { toastManager } from "~/components/ui/toast";
51import { isHostedStaticApp } from "~/hostedPairing";
52import { environmentCatalog } from "~/connection/catalog";
53import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";
54import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";
55import { useAtomCommand } from "~/state/use-atom-command";
56import { useTheme } from "./useTheme";
57import {
58 environmentSession,
59 environmentSessionStatesAtom,
60 readEnvironmentScope,
61 useEnvironmentScope,
62} from "~/state/session";
63import { environmentIdsKey } from "~/state/environmentAtomList";
64import { appAtomRegistry } from "~/rpc/atomRegistry";
65
66const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";
67
68type UnifiedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch;
69
70const clientSettingsListeners = new Set<() => void>();
71const clientSettingsHydrationListeners = new Set<() => void>();
72type ClientSettingsHydrationStatus = "pending" | "ready" | "failed" | "retrying";
73let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
74let clientSettingsHydrationStatus: ClientSettingsHydrationStatus = "pending";
75let clientSettingsHydrationPromise: Promise<void> | null = null;
76let clientSettingsHydrationGeneration = 0;
77let clientSettingsPersistenceQueue: Promise<void> = Promise.resolve();
78let deferredClientSettingsPatchCount = 0;
79let unsubscribeOtherTabClientSettings: (() => void) | null = null;
80
81function emitClientSettingsChange() {
82 for (const listener of clientSettingsListeners) {
83 listener();
84 }
85}
86
87function emitClientSettingsHydrationChange() {
88 for (const listener of clientSettingsHydrationListeners) {
89 listener();
90 }
91}
92
93function getClientSettingsSnapshot(): ClientSettings {
94 return clientSettingsSnapshot;
95}
96
97function replaceClientSettingsSnapshot(settings: ClientSettings): void {
98 clientSettingsSnapshot = settings;
99 emitClientSettingsChange();
100}
101
102function setClientSettingsHydrationStatus(nextStatus: ClientSettingsHydrationStatus): void {
103 if (clientSettingsHydrationStatus === nextStatus) {
104 return;
105 }
106 clientSettingsHydrationStatus = nextStatus;
107 emitClientSettingsHydrationChange();
108}
109
110export function subscribeClientSettings(listener: () => void): () => void {
111 clientSettingsListeners.add(listener);
112 void hydrateClientSettings().catch(() => undefined);
113 return () => {
114 clientSettingsListeners.delete(listener);
115 };
116}
117
118export function getClientSettingsHydratedSnapshot(): boolean {
119 return clientSettingsHydrationStatus === "ready";
120}
121
122function getClientSettingsHydrationStatusSnapshot(): ClientSettingsHydrationStatus {
123 return clientSettingsHydrationStatus;
124}
125
126export function subscribeClientSettingsHydration(listener: () => void): () => void {
127 clientSettingsHydrationListeners.add(listener);
128 void hydrateClientSettings().catch(() => undefined);
129 return () => {
130 clientSettingsHydrationListeners.delete(listener);
131 };
132}
133
134async function hydrateClientSettings(): Promise<void> {
135 if (clientSettingsHydrationStatus === "ready") {
136 return;
137 }
138 if (clientSettingsHydrationPromise) {
139 return clientSettingsHydrationPromise;
140 }
141
142 const hydrationGeneration = clientSettingsHydrationGeneration;
143 setClientSettingsHydrationStatus(
144 clientSettingsHydrationStatus === "failed" || clientSettingsHydrationStatus === "retrying"
145 ? "retrying"
146 : "pending",
147 );
148 const nextHydration = (async () => {
149 try {
150 const persistedSettings = await ensureLocalApi().persistence.getClientSettings();
151 if (hydrationGeneration !== clientSettingsHydrationGeneration) {
152 return;
153 }
154 if (persistedSettings) {
155 replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings });
156 }
157 unsubscribeOtherTabClientSettings ??= subscribeBrowserClientSettingsWrites(
158 refreshClientSettingsFromOtherTab,
159 );
160 setClientSettingsHydrationStatus("ready");
161 } catch (error) {
162 if (hydrationGeneration === clientSettingsHydrationGeneration) {
163 setClientSettingsHydrationStatus("failed");
164 }
165 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, {
166 operation: "hydrate",
167 ...safeErrorLogAttributes(error),
168 });
169 throw error;
170 }
171 })();
172
173 const hydrationPromise = nextHydration.finally(() => {
174 if (clientSettingsHydrationPromise === hydrationPromise) {
175 clientSettingsHydrationPromise = null;
176 }
177 });
178 clientSettingsHydrationPromise = hydrationPromise;
179
180 return clientSettingsHydrationPromise;
181}
182
183function refreshClientSettingsFromOtherTab(): void {
184 void enqueueClientSettingsPersistence(async () => {
185 const persistedSettings = await ensureLocalApi().persistence.getClientSettings();
186 if (persistedSettings) {
187 replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings });
188 }
189 }).catch((error) => {
190 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} refresh failed`, {
191 operation: "refresh",
192 ...safeErrorLogAttributes(error),
193 });
194 });
195}
196
197const defaultClientSettingsPersistence = (settings: ClientSettings): Promise<void> =>
198 ensureLocalApi().persistence.setClientSettings(settings);
199
200function enqueueClientSettingsPersistence<A>(work: () => Promise<A>): Promise<A> {
201 const result = clientSettingsPersistenceQueue.then(work);
202 clientSettingsPersistenceQueue = result.then(
203 () => undefined,
204 () => undefined,
205 );
206 return result;
207}
208
209export function persistClientSettingsPatch(
210 patch: ClientSettingsPatch,
211 persist: (settings: ClientSettings) => Promise<void> = defaultClientSettingsPersistence,
212): Promise<void> {
213 // Patches queued before hydration must publish before newer optimistic patches.
214 const deferPatch =
215 clientSettingsHydrationStatus !== "ready" || deferredClientSettingsPatchCount > 0;
216 if (deferPatch) {
217 deferredClientSettingsPatchCount += 1;
218 } else {
219 replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch });
220 }
221 return enqueueClientSettingsPersistence(async () => {
222 if (deferPatch) {
223 try {
224 if (clientSettingsHydrationStatus !== "ready") {
225 await hydrateClientSettings();
226 }
227 } finally {
228 deferredClientSettingsPatchCount -= 1;
229 }
230 }
231 // Also reapplies an optimistic patch that a refresh from another tab replaced.
232 const current = getClientSettingsSnapshot();
233 if (
234 !Object.entries(patch).every(([key, value]) => current[key as keyof ClientSettings] === value)
235 ) {
236 replaceClientSettingsSnapshot({ ...current, ...patch });
237 }
238 await persist(getClientSettingsSnapshot());
239 }).catch((error) => {
240 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, {
241 operation: "persist",
242 ...safeErrorLogAttributes(error),
243 });
244 });
245}
246
247/** Reapply to newer snapshots after pending writes to avoid losing concurrent changes. */
248export async function persistClientSettingsUpdate(
249 update: (current: ClientSettings) => ClientSettings,
250 persist: (settings: ClientSettings) => Promise<void> = defaultClientSettingsPersistence,
251): Promise<ClientSettings> {
252 return enqueueClientSettingsPersistence(async () => {
253 if (clientSettingsHydrationStatus !== "ready") {
254 await hydrateClientSettings();
255 }
256 for (;;) {
257 const current = getClientSettingsSnapshot();
258 const next = update(current);
259 await persist(next);
260 if (getClientSettingsSnapshot() === current) {
261 replaceClientSettingsSnapshot(next);
262 return next;
263 }
264 }
265 });
266}
267
268const SERVER_SETTINGS_KEYS = new Set<string>(Struct.keys(ServerSettings.fields));
269
270function splitPatch(patch: UnifiedSettingsPatch): {
271 serverPatch: ServerSettingsPatch;
272 clientPatch: ClientSettingsPatch;
273} {
274 const serverPatch: Record<string, unknown> = {};
275 const clientPatch: Record<string, unknown> = {};
276 for (const [key, value] of Object.entries(patch)) {
277 if (SERVER_SETTINGS_KEYS.has(key)) {
278 serverPatch[key] = value;
279 } else {
280 clientPatch[key] = value;
281 }
282 }
283 return {
284 serverPatch: serverPatch as ServerSettingsPatch,
285 clientPatch: clientPatch as ClientSettingsPatch,
286 };
287}
288
289export function getClientSettings(): ClientSettings {
290 return getClientSettingsSnapshot();
291}
292
293export function isClientSettingsHydrated(): boolean {
294 return getClientSettingsHydratedSnapshot();
295}
296
297/** Failed reads reject and remain retryable so defaults cannot overwrite saved preferences. */
298export function ensureClientSettingsHydrated(): Promise<void> {
299 return hydrateClientSettings();
300}
301
302export function useClientSettingsHydrated(): boolean {
303 return useSyncExternalStore(
304 subscribeClientSettingsHydration,
305 getClientSettingsHydratedSnapshot,
306 () => false,
307 );
308}
309
310export function useClientSettingsHydrationStatus(): ClientSettingsHydrationStatus {
311 return useSyncExternalStore(
312 subscribeClientSettingsHydration,
313 getClientSettingsHydrationStatusSnapshot,
314 () => "pending",
315 );
316}
317
318const getDefaultClientSettings = () => DEFAULT_CLIENT_SETTINGS;
319const selectWholeClientSettings = (settings: ClientSettings) => settings;
320
321/** Structurally equal selections retain their reference and skip rendering. */
322export function useClientSettings<T = ClientSettings>(
323 selector?: (settings: ClientSettings) => T,
324): T {
325 return useSyncExternalStoreWithSelector(
326 subscribeClientSettings,
327 getClientSettingsSnapshot,
328 getDefaultClientSettings,
329 selector ?? (selectWholeClientSettings as (settings: ClientSettings) => T),
330 Equal.equals,
331 );
332}
333
334export function mergeEnvironmentSettings(
335 serverSettings: ServerSettings,
336 clientSettings: ClientSettings,
337): UnifiedSettings {
338 // Older untyped adapters can return retired client keys; server values must win.
339 return {
340 ...clientSettings,
341 ...serverSettings,
342 ...resolveModelPreferences(serverSettings, clientSettings),
343 };
344}
345
346const selectLegacyModelPreferences = (settings: ClientSettings) => ({
347 favorites: settings.favorites,
348 providerModelPreferences: settings.providerModelPreferences,
349});
350
351// Servers that predate `migrateModelPreferences` drop it and keep reporting null favorites.
352const attemptedMigrationGenerations = new Map<EnvironmentId, number>();
353
354const modelPreferencesMigrationTargetsAtom = Atom.make((get) =>
355 [...get(environmentCatalog.catalogValueAtom).entries.keys()].flatMap((environmentId) => {
356 const generation = Option.match(
357 AsyncResult.value(get(environmentCatalog.stateAtom(environmentId))),
358 {
359 onNone: () => null,
360 onSome: (connection) => (connection.phase === "connected" ? connection.generation : null),
361 },
362 );
363 const settings = get(serverEnvironment.settingsValueAtom(environmentId));
364 return generation === null || settings === null
365 ? []
366 : [{ environmentId, generation, settings }];
367 }),
368).pipe(Atom.withLabel("web-model-preferences-migration-targets"));
369
370function migrateModelPreferences(): void {
371 if (clientSettingsHydrationStatus !== "ready") return;
372 for (const { environmentId, generation, settings } of appAtomRegistry.get(
373 modelPreferencesMigrationTargetsAtom,
374 )) {
375 if (attemptedMigrationGenerations.get(environmentId) === generation) continue;
376 const patch = modelPreferencesMigrationPatch(settings, clientSettingsSnapshot);
377 if (!patch) continue;
378 attemptedMigrationGenerations.set(environmentId, generation);
379 void runAtomCommand(
380 appAtomRegistry,
381 serverEnvironment.updateSettings,
382 { environmentId, input: { patch } },
383 { reportFailure: false },
384 );
385 }
386}
387
388/** Moves this client's legacy model preferences onto each server, once per connection. */
389export function startModelPreferencesMigration(): () => void {
390 const unsubscribes = [
391 appAtomRegistry.subscribe(modelPreferencesMigrationTargetsAtom, migrateModelPreferences),
392 subscribeClientSettings(migrateModelPreferences),
393 subscribeClientSettingsHydration(migrateModelPreferences),
394 ];
395 migrateModelPreferences();
396 return () => {
397 for (const unsubscribe of unsubscribes) unsubscribe();
398 };
399}
400
401export function useMergedSettings<T = UnifiedSettings>(
402 serverSettings: ServerSettings,
403 selector?: (settings: UnifiedSettings) => T,
404): T {
405 return useClientSettings((clientSettings) => {
406 const merged = mergeEnvironmentSettings(serverSettings, clientSettings);
407 return selector ? selector(merged) : (merged as T);
408 });
409}
410
411function resolveEnvironmentIdentificationMode(input: {
412 mode: EnvironmentIdentificationMode;
413 settingsHydrated: boolean;
414 paletteThemeActive?: boolean;
415 paletteThemeAllowsArtwork?: boolean;
416}): EnvironmentIdentificationMode {
417 if (!input.settingsHydrated) return "none";
418 const isArtwork = input.mode !== "pill" && input.mode !== "none";
419 return input.paletteThemeActive && !input.paletteThemeAllowsArtwork && isArtwork
420 ? "pill"
421 : input.mode;
422}
423
424export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMode {
425 const settingsHydrated = useClientSettingsHydrated();
426 const mode = useClientSettings((settings) => settings.environmentIdentificationMode);
427 const { resolvedTheme, theme, themeHalves } = useTheme();
428 const previewSidebarArtwork = useSyncExternalStore(
429 subscribeToThemePreview,
430 getThemePreviewSidebarArtwork,
431 () => null,
432 );
433 const activeTheme = resolveThemeHalf(theme, themeHalves, resolvedTheme);
434 const activeThemeDefinition = getThemeDefinition(activeTheme);
435 return resolveEnvironmentIdentificationMode({
436 mode,
437 settingsHydrated,
438 paletteThemeActive: previewSidebarArtwork !== null || activeThemeDefinition !== null,
439 paletteThemeAllowsArtwork: previewSidebarArtwork ?? themeAllowsSidebarArtwork(activeTheme),
440 });
441}
442
443export async function prepareModelPreferencesPatch(
444 settings: ServerSettings,
445 patch: ServerSettingsPatch,
446): Promise<ServerSettingsPatch> {
447 if (settings.favorites === null || settings.providerModelPreferences === null) {
448 await ensureClientSettingsHydrated();
449 }
450 return { ...modelPreferencesMigrationPatch(settings, getClientSettings()), ...patch };
451}
452
453export function useUpdateEnvironmentModelPreferences(environmentId: EnvironmentId | null) {
454 const settings = useAtomValue(
455 environmentId ? serverEnvironment.settingsValueAtom(environmentId) : noEnvironmentSettingsAtom,
456 );
457 const save = useAtomCommand(serverEnvironment.updateSettings, "model preferences update");
458 return async (patch: ServerSettingsPatch) => {
459 if (!environmentId || !settings) return;
460 let prepared: ServerSettingsPatch;
461 try {
462 prepared = await prepareModelPreferencesPatch(settings, patch);
463 } catch {
464 toastManager.add({
465 type: "error",
466 title: "Model preferences not saved",
467 description: "Could not load saved model preferences. Try again.",
468 });
469 return;
470 }
471 return save({ environmentId, input: { patch: prepared } });
472 };
473}
474
475/** Send the desired state so two clients adding a favorite do not cancel each other. */
476export function useToggleEnvironmentModelFavorite(environmentId: EnvironmentId | null) {
477 const registry = use(RegistryContext);
478 const pendingFavorites = useRef(
479 new Map<string, { favorite: boolean; settings: ServerSettings; pending: boolean }>(),
480 );
481 const save = useUpdateEnvironmentModelPreferences(environmentId);
482 return async (provider: ProviderInstanceId, model: string) => {
483 if (!environmentId) return;
484 const settingsAtom = serverEnvironment.settingsValueAtom(environmentId);
485 const settings = registry.get(settingsAtom);
486 if (!settings) return;
487 if (settings.favorites === null) {
488 try {
489 await ensureClientSettingsHydrated();
490 } catch {
491 toastManager.add({
492 type: "error",
493 title: "Favorites not saved",
494 description: "Could not load saved model preferences. Try again.",
495 });
496 return;
497 }
498 }
499 const currentSettings = registry.get(settingsAtom) ?? settings;
500 const favorites = resolveModelPreferences(currentSettings, getClientSettings()).favorites;
501 const key = JSON.stringify([environmentId, provider, model]);
502 const previous = pendingFavorites.current.get(key);
503 // Keep the acknowledged choice until the config stream catches up with the RPC reply.
504 const previousFavorite =
505 previous && (previous.pending || previous.settings.favorites === currentSettings.favorites)
506 ? previous.favorite
507 : favorites.some((favorite) => favorite.provider === provider && favorite.model === model);
508 const pending = {
509 favorite: !previousFavorite,
510 settings: currentSettings,
511 pending: true,
512 };
513 pendingFavorites.current.set(key, pending);
514 const result = await save({
515 setModelFavorites: [{ provider, model, favorite: pending.favorite }],
516 });
517 if (
518 result &&
519 AsyncResult.isSuccess(result) &&
520 registry.get(settingsAtom)?.favorites === currentSettings.favorites
521 ) {
522 pending.pending = false;
523 }
524 if (pending.pending && pendingFavorites.current.get(key) === pending) {
525 if (
526 previous &&
527 !previous.pending &&
528 registry.get(settingsAtom)?.favorites === previous.settings.favorites
529 ) {
530 pendingFavorites.current.set(key, previous);
531 } else {
532 pendingFavorites.current.delete(key);
533 }
534 }
535 };
536}
537
538const noEnvironmentSettingsAtom = Atom.make<ServerSettings | null>(null);
539
540export function useEnvironmentModelPreferences(environmentId: EnvironmentId | null) {
541 const settings = useAtomValue(
542 environmentId ? serverEnvironment.settingsValueAtom(environmentId) : noEnvironmentSettingsAtom,
543 );
544 const legacy = useClientSettings(selectLegacyModelPreferences);
545 return resolveModelPreferences(settings ?? DEFAULT_SERVER_SETTINGS, legacy);
546}
547
548export function useEnvironmentSettings<T = UnifiedSettings>(
549 environmentId: EnvironmentId,
550 selector?: (settings: UnifiedSettings) => T,
551): T {
552 const serverSettings = useAtomValue(serverEnvironment.settingsValueAtom(environmentId));
553 return useMergedSettings(serverSettings ?? DEFAULT_SERVER_SETTINGS, selector);
554}
555
556export function usePersistEnvironmentProviderInstanceMutation(environmentId: EnvironmentId) {
557 const mutateProviderInstance = useAtomCommand(serverEnvironment.mutateProviderInstance, {
558 reportFailure: false,
559 });
560 return (providerInstanceMutation: ProviderInstanceMutation, patch: ServerSettingsPatch = {}) =>
561 mutateProviderInstance({
562 environmentId,
563 input: { patch, providerInstanceMutation },
564 });
565}
566
567export function usePrimarySettings<T = UnifiedSettings>(
568 selector?: (settings: UnifiedSettings) => T,
569): T {
570 const settings = useAtomValue(primaryServerSettingsAtom);
571 return useMergedSettings(settings, selector);
572}
573
574export const PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE =
575 "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.";
576
577/** Hosted clients have no primary: usePrimarySettings reads defaults and cannot write. */
578export function usePrimarySettingsAvailable(): boolean {
579 const primaryEnvironment = usePrimaryEnvironment();
580 return primaryEnvironment !== null || !isHostedStaticApp();
581}
582
583function useSharedSettingsSyncTargetIds(includePending = false): ReadonlyArray<EnvironmentId> {
584 const { environments } = useEnvironments();
585 const targetIds = environments
586 .filter(supportsSharedSettingsSync)
587 .map(({ environmentId }) => environmentId);
588 const results = useAtomValue(environmentSessionStatesAtom(environmentIdsKey(targetIds)));
589 return targetIds.flatMap((environmentId, index) => {
590 const result = results[index]!;
591 // Let the server authorize shared edits with cold grants; suggestions still need confirmed grants.
592 if (includePending && result._tag === "Initial") {
593 return [environmentId];
594 }
595 const session = result._tag === "Failure" ? null : AsyncResult.getOrElse(result, () => null);
596 return session !== null && sessionGrantsScope(session, AuthSettingsWriteScope)
597 ? [environmentId]
598 : [];
599 });
600}
601
602function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
603 useEnvironmentScope(environmentId, AuthSettingsWriteScope);
604 const persist = useAtomCommand(serverEnvironment.updateSettings, {
605 label: "server settings update",
606 reportFailure: false,
607 });
608 const { environments } = useEnvironments();
609 const persistServerSettings = async (
610 request: Parameters<typeof persist>[0],
611 onFailed?: () => void,
612 ) => {
613 const result = await persist(request);
614 if (result._tag === "Failure") onFailed?.();
615 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
616 const label =
617 environments.find((environment) => environment.environmentId === request.environmentId)
618 ?.label ?? request.environmentId;
619 const error = squashAtomCommandFailure(result);
620 toastManager.add({
621 type: "error",
622 title: "Setting not saved",
623 description: `Could not save on ${label}: ${error instanceof Error ? error.message : "The save failed. Try reconnecting and saving again."}`,
624 });
625 }
626 return result;
627 };
628 const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(true);
629 const updateSettings = (
630 patch: UnifiedSettingsPatch,
631 options?: { readonly onFailed?: () => void; readonly onSaved?: () => void },
632 ) => {
633 const { serverPatch, clientPatch } = splitPatch(patch);
634
635 const canWriteServerPatch =
636 environmentId === null ||
637 requiredScopesForServerSettingsPatch(serverPatch).every((scope) =>
638 readEnvironmentScope(environmentId, scope),
639 );
640 if (Object.keys(serverPatch).length > 0 && !canWriteServerPatch) {
641 options?.onFailed?.();
642 toastManager.add({
643 type: "warning",
644 title: "Setting not saved",
645 description: `This connection lacks permission to change settings on ${environments.find((target) => target.environmentId === environmentId)?.label ?? "the selected environment"}.`,
646 });
647 }
648 if (Object.keys(serverPatch).length > 0 && canWriteServerPatch) {
649 const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch);
650 // Dropping the write silently leaves the control looking saved.
651 const warnUnsaved = (description = PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE) => {
652 options?.onFailed?.();
653 return toastManager.add({
654 type: "warning",
655 title: "Setting not saved",
656 description,
657 });
658 };
659 if (Object.keys(localPatch).length > 0) {
660 if (environmentId) {
661 void persistServerSettings(
662 { environmentId, input: { patch: localPatch } },
663 options?.onFailed,
664 ).then((result) => {
665 if (result._tag === "Success") options?.onSaved?.();
666 });
667 } else {
668 warnUnsaved();
669 }
670 }
671 if (Object.keys(sharedPatch).length > 0) {
672 const sourceSettings = environments.find((target) => target.environmentId === environmentId)
673 ?.serverConfig?.settings;
674 const targets = new Set(sharedSettingsSyncTargetIds);
675 if (environmentId) {
676 targets.add(environmentId);
677 }
678 const writes: Array<Parameters<typeof persist>[0]> = [];
679 const deniedLabels: string[] = [];
680 for (const targetId of targets) {
681 const target = environments.find((candidate) => candidate.environmentId === targetId);
682 const targetPatch = filterSharedServerPatch(
683 sharedPatch,
684 target?.serverConfig?.settings,
685 sourceSettings,
686 targetId === environmentId,
687 );
688 if (Object.keys(targetPatch).length === 0) continue;
689 const session = appAtomRegistry.get(environmentSession.sessionStateAtom(targetId));
690 if (
691 session._tag !== "Initial" &&
692 !requiredScopesForServerSettingsPatch(sharedPatch).every((scope) =>
693 readEnvironmentScope(targetId, scope),
694 )
695 ) {
696 deniedLabels.push(target?.label ?? targetId);
697 continue;
698 }
699 writes.push({
700 environmentId: targetId,
701 input: { patch: targetPatch },
702 });
703 }
704 if (writes.length === 0) {
705 warnUnsaved(
706 deniedLabels.length > 0
707 ? `This connection lacks permission to change settings on ${deniedLabels.join(", ")}.`
708 : targets.size > 0
709 ? "Update older servers to save this setting."
710 : undefined,
711 );
712 } else {
713 void Promise.all(
714 writes.map(async (request) => ({
715 label:
716 environments.find((target) => target.environmentId === request.environmentId)
717 ?.label ?? request.environmentId,
718 result: await persist(request),
719 })),
720 ).then((outcomes) => {
721 if (
722 deniedLabels.length > 0 ||
723 outcomes.some(({ result }) => result._tag === "Failure")
724 ) {
725 options?.onFailed?.();
726 }
727 const failures = outcomes.flatMap(({ label, result }) => {
728 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return [];
729 const error = squashAtomCommandFailure(result);
730 return [
731 `Could not save on ${label}: ${error instanceof Error ? error.message : "The save failed. Try reconnecting and saving again."}`,
732 ];
733 });
734 if (
735 deniedLabels.length === 0 &&
736 outcomes.every(({ result }) => result._tag === "Success")
737 ) {
738 options?.onSaved?.();
739 }
740 if (failures.length === 0) return;
741 const saved = outcomes
742 .filter(({ result }) => result._tag === "Success")
743 .map(({ label }) => label);
744 toastManager.add({
745 type: "error",
746 title: saved.length > 0 ? "Setting saved on some environments" : "Setting not saved",
747 description: [
748 ...failures,
749 ...(saved.length > 0 ? [`Saved on ${saved.join(", ")}.`] : []),
750 ].join("\n"),
751 });
752 });
753 }
754 }
755 }
756 if (Object.keys(clientPatch).length > 0) {
757 void persistClientSettingsPatch(clientPatch);
758 }
759 };
760
761 return updateSettings;
762}
763
764export function useUpdateEnvironmentSettings(environmentId: EnvironmentId | null) {
765 return useUpdateSettingsTarget(environmentId);
766}
767
768export function useUpdatePrimarySettings() {
769 return useUpdateSettingsTarget(usePrimaryEnvironment()?.environmentId ?? null);
770}
771
772export function updateClientSettings(patch: ClientSettingsPatch): Promise<void> {
773 return persistClientSettingsPatch(patch);
774}
775
776export function __resetClientSettingsPersistenceForTests(): void {
777 clientSettingsHydrationGeneration += 1;
778 clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
779 clientSettingsHydrationStatus = "pending";
780 clientSettingsHydrationPromise = null;
781 clientSettingsPersistenceQueue = Promise.resolve();
782 deferredClientSettingsPatchCount = 0;
783 unsubscribeOtherTabClientSettings?.();
784 unsubscribeOtherTabClientSettings = null;
785 clientSettingsListeners.clear();
786 clientSettingsHydrationListeners.clear();
787}
788
789export function __setClientSettingsForTests(settings: ClientSettings): void {
790 clientSettingsHydrationGeneration += 1;
791 clientSettingsSnapshot = settings;
792 clientSettingsHydrationStatus = "ready";
793 clientSettingsHydrationPromise = null;
794}