1import { AuthSettingsWriteScope } from "@t3tools/contracts";
2import { useEnvironmentsWithScope } from "../../state/session";
3import {
4 requiredScopesForServerSettingsPatch,
5 EnvironmentAuthorizationError,
6} from "@t3tools/contracts";
7import { readEnvironmentScope } from "../../state/session";
8import * as Cause from "effect/Cause";
9import { AsyncResult } from "effect/reactivity";
10import {
11 DEFAULT_SERVER_SETTINGS,
12 type ProjectScopedServerSettingKey,
13 type ServerSettings,
14 type UnifiedSettings,
15} from "@t3tools/contracts";
16import { useCallback, useMemo } from "react";
17
18import {
19 mergeEnvironmentSettings,
20 persistClientSettingsPatch,
21 useClientSettings,
22 useMigrateModelPreferences,
23} from "../../hooks/useSettings";
24import { serverEnvironment } from "../../state/server";
25import { useAtomCommand } from "../../state/use-atom-command";
26import { toastManager } from "../ui/toast";
27import { useOptionalSettingsScope, useSettingsScope } from "./SettingsScopeContext";
28import {
29 persistScopedSettingsPatch,
30 planProjectOverridesClear,
31 planScopedSettingsClear,
32 planScopedSettingsPatch,
33 scopedSettingsAreMixed,
34 scopedSettingsSource,
35 type ProjectOverrideEntry,
36 type ScopedSettingsPatch,
37} from "./scopedSettings";
38
39/** Effective settings for the representative target: project overrides applied on top of its environment. */
40export function useScopedSettings<T = UnifiedSettings>(
41 selector?: (settings: UnifiedSettings) => T,
42): T {
43 const { target } = useSettingsScope();
44 const clientSettings = useClientSettings();
45 const serverSettings = target?.settings ?? DEFAULT_SERVER_SETTINGS;
46 useMigrateModelPreferences(target?.environmentId ?? null, target?.settings ?? null);
47 const settings = useMemo(
48 () => mergeEnvironmentSettings(serverSettings, clientSettings),
49 [clientSettings, serverSettings],
50 );
51 return useMemo(() => (selector ? selector(settings) : (settings as T)), [selector, settings]);
52}
53
54export function useScopedSettingsMixed(keys: readonly (keyof ServerSettings)[]): boolean {
55 const { targets } = useSettingsScope();
56 return scopedSettingsAreMixed(targets, keys);
57}
58
59/** Where the keys' effective values come from across the selected targets. */
60export function useScopedSettingSource(keys: readonly (keyof ServerSettings)[]) {
61 const { targets } = useSettingsScope();
62 return scopedSettingsSource(targets, keys);
63}
64
65function useRunScopedPlan() {
66 const persistServer = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });
67 return useCallback(
68 (plan: ReturnType<typeof planScopedSettingsPatch>) => {
69 if (plan.unavailableReason) {
70 toastManager.add({
71 type: "warning",
72 title: "Setting not saved",
73 description: plan.unavailableReason,
74 });
75 return;
76 }
77 void persistScopedSettingsPatch(
78 plan,
79 async (request) => {
80 const missing = requiredScopesForServerSettingsPatch(request.input.patch).find(
81 (scope) => !readEnvironmentScope(request.environmentId, scope),
82 );
83 if (missing)
84 return AsyncResult.failure(
85 Cause.fail(
86 new EnvironmentAuthorizationError({
87 requiredScope: missing,
88 message: "This connection lacks permission to change these settings.",
89 }),
90 ),
91 );
92 return persistServer(request);
93 },
94 persistClientSettingsPatch,
95 ).then(({ failedEnvironments, savedEnvironments, savedEnvironmentCount }) => {
96 if (failedEnvironments.length === 0) return;
97 toastManager.add({
98 type: "error",
99 title:
100 savedEnvironmentCount > 0 ? "Setting saved on some environments" : "Setting not saved",
101 description: [
102 ...failedEnvironments.map(
103 ({ label, message }) => `Could not save on ${label}: ${message}`,
104 ),
105 ...(savedEnvironmentCount > 0
106 ? [`Saved on ${savedEnvironments.map(({ label }) => label).join(", ")}.`]
107 : []),
108 ].join("\n"),
109 });
110 });
111 },
112 [persistServer],
113 );
114}
115
116export function useUpdateScopedSettings() {
117 const { scope, environments } = useSettingsScope();
118 const run = useRunScopedPlan();
119 return useCallback(
120 (patch: ScopedSettingsPatch) => run(planScopedSettingsPatch(scope, environments, patch)),
121 [environments, run, scope],
122 );
123}
124
125/**
126 * Drop the project overrides for `keys` so the selected checkouts inherit
127 * again. Rows also render outside the settings layout (provider cards,
128 * dialogs), where there is no scope and nothing to clear.
129 */
130export function useClearScopedSettings() {
131 const context = useOptionalSettingsScope();
132 const run = useRunScopedPlan();
133 return useCallback(
134 (keys: readonly ProjectScopedServerSettingKey[]) => {
135 if (context === null) return;
136 run(planScopedSettingsClear(context.scope, context.environments, keys));
137 },
138 [context, run],
139 );
140}
141
142/** Clear `keys` on specific project entries, from an environment scope's chain popover. */
143export function useClearProjectOverrides() {
144 const context = useOptionalSettingsScope();
145 const run = useRunScopedPlan();
146 return useCallback(
147 (entries: readonly ProjectOverrideEntry[], keys: readonly ProjectScopedServerSettingKey[]) => {
148 if (context === null) return;
149 run(planProjectOverridesClear(context.environments, entries, keys));
150 },
151 [context, run],
152 );
153}
154
155export function useScopedSettingsWriteAllowed() {
156 const { connectedEnvironments } = useSettingsScope();
157 const writable = useEnvironmentsWithScope(connectedEnvironments, AuthSettingsWriteScope);
158 return (
159 connectedEnvironments.length > 0 &&
160 connectedEnvironments.every((target) => writable.has(target.environmentId))
161 );
162}