1import { RegistryContext } from "@effect/atom-react";
2import {
3 UiSettingsSync,
4 UiSettingsSyncCache,
5 createUiSettingsSyncAtoms,
6 type UiSettingsSyncCache as SyncCache,
7} from "@t3tools/client-runtime/state/ui-settings-sync";
8import { UiSettingsValues, type EnvironmentId, type UiSettingsProfile } from "@t3tools/contracts";
9import * as Cause from "effect/Cause";
10import * as Equal from "effect/Equal";
11import * as Schema from "effect/Schema";
12import * as Struct from "effect/Struct";
13import { AsyncResult } from "effect/reactivity";
14import { use, useEffect, useSyncExternalStore } from "react";
15import { connectionAtomRuntime } from "../connection/runtime";
16import {
17 ensureClientSettingsHydrated,
18 getClientSettings,
19 persistClientSettingsPatch,
20 subscribeClientSettingsEdits,
21} from "../hooks/useSettings";
22import {
23 applyUiThemePreferences,
24 readUiThemePreferences,
25 subscribeThemeEdits,
26} from "../hooks/useTheme";
27import { subscribeToCustomThemeEdits } from "../themePalette";
28import { appAtomRegistry } from "../rpc/atomRegistry";
29import { useEnvironments } from "./environments";
30import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
31
32export const uiSettingsSyncAtoms = createUiSettingsSyncAtoms(connectionAtomRuntime);
33const STORAGE_KEY = "knife:ui-settings-sync:v1";
34const json = Schema.fromJsonString(UiSettingsSyncCache);
35const decodeCache = Schema.decodeUnknownSync(json);
36const decodeValues = Schema.decodeUnknownSync(UiSettingsValues);
37const listeners = new Set<() => void>();
38let state: {
39 controller: UiSettingsSync | null;
40 environmentId: EnvironmentId | null;
41 status: "disabled" | UiSettingsSync["snapshot"]["status"];
42 conflicts: UiSettingsSync["snapshot"]["conflicts"];
43} = { controller: null, environmentId: null, status: "disabled", conflicts: [] };
44
45function notify() {
46 for (const listener of listeners) listener();
47}
48
49export function personalUiSettingsSyncEnabled() {
50 if (state.controller) return true;
51 try {
52 return window.localStorage.getItem(STORAGE_KEY) !== null;
53 } catch {
54 return true;
55 }
56}
57
58export function useUiSettingsSync() {
59 return useSyncExternalStore(
60 (listener) => {
61 listeners.add(listener);
62 return () => {
63 listeners.delete(listener);
64 };
65 },
66 () => state,
67 );
68}
69
70async function apply(values: UiSettingsValues) {
71 if (values.theme && !Equal.equals(readUiThemePreferences(), values.theme)) {
72 applyUiThemePreferences(values.theme);
73 }
74 const patch = Struct.omit(values, ["theme"]);
75 const local = getClientSettings();
76 for (const key of Object.keys(patch) as Array<keyof typeof patch>) {
77 if (Equal.equals(local[key], patch[key])) delete patch[key];
78 }
79 if (Object.keys(patch).length > 0) await persistClientSettingsPatch(patch, undefined, "sync");
80}
81
82function install(cache: SyncCache) {
83 const next = new UiSettingsSync(cache, {
84 save: async (value) => {
85 window.localStorage.setItem(STORAGE_KEY, Schema.encodeSync(json)(value));
86 },
87 apply,
88 patch: async (input) => {
89 const result = await runAtomCommand(
90 appAtomRegistry,
91 uiSettingsSyncAtoms.patch,
92 {
93 environmentId: cache.environmentId,
94 input,
95 },
96 { reportFailure: false },
97 );
98 if (AsyncResult.isFailure(result)) throw Cause.squash(result.cause);
99 return result.value;
100 },
101 });
102 state = { ...state, controller: next };
103 const update = () => {
104 if (state.controller !== next) return;
105 state = { controller: next, environmentId: cache.environmentId, ...next.snapshot };
106 notify();
107 };
108 next.subscribe(update);
109 update();
110 return next;
111}
112
113export async function enableUiSettingsSync(
114 environmentId: EnvironmentId,
115 profile: UiSettingsProfile,
116 mode: "adopt" | "publish",
117) {
118 await ensureClientSettingsHydrated();
119 const local =
120 mode === "publish"
121 ? { ...decodeValues(getClientSettings()), theme: readUiThemePreferences() }
122 : null;
123 const cache: SyncCache = { environmentId, profile, pending: { patch: {}, baseRevisions: {} } };
124 // Persist consent before installing a controller that can write to the environment.
125 await state.controller?.stop();
126 window.localStorage.setItem(STORAGE_KEY, Schema.encodeSync(json)(cache));
127 const next = install(cache);
128 if (local) next.edit(local);
129 await next.receive(profile);
130}
131
132export async function disableUiSettingsSync() {
133 const previous = state.controller;
134 state = { ...state, controller: null };
135 await previous?.stop();
136 window.localStorage.removeItem(STORAGE_KEY);
137 state = { controller: null, environmentId: null, status: "disabled", conflicts: [] };
138 notify();
139}
140
141/** Mounted once for the app; individual settings views never own the sync lifecycle. */
142export function usePersonalUiSettingsSync() {
143 const registry = use(RegistryContext);
144 const sync = useUiSettingsSync();
145 const { environments } = useEnvironments();
146 const environment = environments.find((entry) => entry.environmentId === sync.environmentId);
147 const activeController = sync.controller;
148 const connected =
149 environment?.connection.phase === "connected" &&
150 environment.serverConfig?.environment.capabilities.uiSettingsSync === true;
151
152 useEffect(() => {
153 let disposed = false;
154 const unlisten = subscribeClientSettingsEdits((patch) => {
155 state.controller?.edit(decodeValues(patch));
156 });
157 const themeChanged = () => {
158 if (!state.controller) return;
159 try {
160 state.controller.edit({ theme: readUiThemePreferences() });
161 } catch {
162 state.controller.disconnect();
163 }
164 };
165 const untheme = subscribeThemeEdits(themeChanged);
166 const uncustom = subscribeToCustomThemeEdits(themeChanged);
167 void ensureClientSettingsHydrated()
168 .then(() => {
169 if (disposed || state.controller) return;
170 const raw = window.localStorage.getItem(STORAGE_KEY);
171 if (raw) return install(decodeCache(raw)).restore();
172 })
173 .catch(() => {
174 state = { ...state, status: "error" };
175 notify();
176 });
177 return () => {
178 disposed = true;
179 unlisten();
180 untheme();
181 uncustom();
182 };
183 }, []);
184
185 useEffect(() => {
186 const current = activeController;
187 if (!current || !sync.environmentId) return;
188 if (!connected) {
189 current.disconnect();
190 return;
191 }
192 let disposed = false;
193 let unsubscribe = () => {};
194 const target = { environmentId: sync.environmentId, input: {} };
195 void runAtomCommand(registry, uiSettingsSyncAtoms.read, target, { reportFailure: false }).then(
196 async (result) => {
197 if (disposed) return;
198 if (AsyncResult.isFailure(result)) {
199 current.disconnect();
200 return;
201 }
202 await current.receive(result.value);
203 if (disposed) return;
204 unsubscribe = registry.subscribe(
205 uiSettingsSyncAtoms.changes(target),
206 (update) => {
207 if (AsyncResult.isSuccess(update)) void current.receive(update.value);
208 else if (AsyncResult.isFailure(update)) current.disconnect();
209 },
210 { immediate: true },
211 );
212 },
213 );
214 return () => {
215 disposed = true;
216 unsubscribe();
217 current.disconnect();
218 };
219 }, [activeController, connected, registry, sync.environmentId]);
220}