| 1 | import { RegistryContext } from "@effect/atom-react"; |
| 2 | import { |
| 3 | UiSettingsSync, |
| 4 | UiSettingsSyncCache, |
| 5 | createUiSettingsSyncAtoms, |
| 6 | type UiSettingsSyncCache as SyncCache, |
| 7 | } from "@t3tools/client-runtime/state/ui-settings-sync"; |
| 8 | import { UiSettingsValues, type EnvironmentId, type UiSettingsProfile } from "@t3tools/contracts"; |
| 9 | import * as Cause from "effect/Cause"; |
| 10 | import * as Equal from "effect/Equal"; |
| 11 | import * as Schema from "effect/Schema"; |
| 12 | import * as Struct from "effect/Struct"; |
| 13 | import { AsyncResult } from "effect/reactivity"; |
| 14 | import { use, useEffect, useSyncExternalStore } from "react"; |
| 15 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 16 | import { |
| 17 | ensureClientSettingsHydrated, |
| 18 | getClientSettings, |
| 19 | persistClientSettingsPatch, |
| 20 | subscribeClientSettingsEdits, |
| 21 | } from "../hooks/useSettings"; |
| 22 | import { |
| 23 | applyUiThemePreferences, |
| 24 | readUiThemePreferences, |
| 25 | subscribeThemeEdits, |
| 26 | } from "../hooks/useTheme"; |
| 27 | import { subscribeToCustomThemeEdits } from "../themePalette"; |
| 28 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 29 | import { useEnvironments } from "./environments"; |
| 30 | import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; |
| 31 | |
| 32 | export const uiSettingsSyncAtoms = createUiSettingsSyncAtoms(connectionAtomRuntime); |
| 33 | const STORAGE_KEY = "knife:ui-settings-sync:v1"; |
| 34 | const json = Schema.fromJsonString(UiSettingsSyncCache); |
| 35 | const decodeCache = Schema.decodeUnknownSync(json); |
| 36 | const decodeValues = Schema.decodeUnknownSync(UiSettingsValues); |
| 37 | const listeners = new Set<() => void>(); |
| 38 | let 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 | |
| 45 | function notify() { |
| 46 | for (const listener of listeners) listener(); |
| 47 | } |
| 48 | |
| 49 | export 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 | |
| 58 | export function useUiSettingsSync() { |
| 59 | return useSyncExternalStore( |
| 60 | (listener) => { |
| 61 | listeners.add(listener); |
| 62 | return () => { |
| 63 | listeners.delete(listener); |
| 64 | }; |
| 65 | }, |
| 66 | () => state, |
| 67 | ); |
| 68 | } |
| 69 | |
| 70 | async 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 | |
| 82 | function 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 | |
| 113 | export 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 | |
| 132 | export 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. */ |
| 142 | export 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 | } |