593 lines · 21.0 KB · TypeScript
RawDownload
/** * Environment-scoped settings hooks. * * Abstracts the split between server-authoritative settings (persisted in * `settings.json` on the server, fetched via `server.getConfig`) and * client-only settings (persisted in localStorage). * * Live server settings always require an environment id. Primary-environment * access is intentionally named as such so environment-sensitive consumers * cannot silently read the wrong server's settings. */import { useCallback, useMemo, useSyncExternalStore } from "react";import { useAtomValue } from "@effect/atom-react";import {  DEFAULT_SERVER_SETTINGS,  type EnvironmentId,  ServerSettings,  type ServerSettingsPatch,} from "@t3tools/contracts";import {  type ClientSettingsPatch,  type ClientSettings,  DEFAULT_CLIENT_SETTINGS,  type EnvironmentIdentificationMode,  type UnifiedSettings,} from "@t3tools/contracts/settings";import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";import {  filterSharedServerPatch,  findSharedSettingsMismatches,  pickSharedServerSettings,  splitSharedServerPatch,  supportsSharedSettingsSync,} from "@t3tools/client-runtime/state/shared-settings";import { ensureLocalApi } from "~/localApi";import {  getThemeDefinition,  getThemePreviewSidebarArtwork,  resolveThemeHalf,  subscribeToThemePreview,  themeAllowsSidebarArtwork,} from "~/themePalette";import * as Struct from "effect/Struct";import { toastManager } from "~/components/ui/toast";import { isHostedStaticApp } from "~/hostedPairing";import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";import { useAtomCommand } from "~/state/use-atom-command";import { useTheme } from "./useTheme"; const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; type UnifiedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch; const clientSettingsListeners = new Set<() => void>();const clientSettingsHydrationListeners = new Set<() => void>();type ClientSettingsHydrationStatus = "pending" | "ready" | "failed" | "retrying";let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;let clientSettingsHydrationStatus: ClientSettingsHydrationStatus = "pending";let clientSettingsHydrationPromise: Promise<void> | null = null;let clientSettingsHydrationGeneration = 0;let clientSettingsPersistenceQueue: Promise<void> = Promise.resolve();let deferredClientSettingsPatchCount = 0; function emitClientSettingsChange() {  for (const listener of clientSettingsListeners) {    listener();  }} function emitClientSettingsHydrationChange() {  for (const listener of clientSettingsHydrationListeners) {    listener();  }} function getClientSettingsSnapshot(): ClientSettings {  return clientSettingsSnapshot;} function replaceClientSettingsSnapshot(settings: ClientSettings): void {  clientSettingsSnapshot = settings;  emitClientSettingsChange();} function setClientSettingsHydrationStatus(nextStatus: ClientSettingsHydrationStatus): void {  if (clientSettingsHydrationStatus === nextStatus) {    return;  }  clientSettingsHydrationStatus = nextStatus;  emitClientSettingsHydrationChange();} function subscribeClientSettings(listener: () => void): () => void {  clientSettingsListeners.add(listener);  void hydrateClientSettings().catch(() => undefined);  return () => {    clientSettingsListeners.delete(listener);  };} function getClientSettingsHydratedSnapshot(): boolean {  return clientSettingsHydrationStatus === "ready";} function getClientSettingsHydrationStatusSnapshot(): ClientSettingsHydrationStatus {  return clientSettingsHydrationStatus;} function subscribeClientSettingsHydration(listener: () => void): () => void {  clientSettingsHydrationListeners.add(listener);  void hydrateClientSettings().catch(() => undefined);  return () => {    clientSettingsHydrationListeners.delete(listener);  };} async function hydrateClientSettings(): Promise<void> {  if (clientSettingsHydrationStatus === "ready") {    return;  }  if (clientSettingsHydrationPromise) {    return clientSettingsHydrationPromise;  }   const hydrationGeneration = clientSettingsHydrationGeneration;  setClientSettingsHydrationStatus(    clientSettingsHydrationStatus === "failed" || clientSettingsHydrationStatus === "retrying"      ? "retrying"      : "pending",  );  const nextHydration = (async () => {    try {      const persistedSettings = await ensureLocalApi().persistence.getClientSettings();      if (hydrationGeneration !== clientSettingsHydrationGeneration) {        return;      }      if (persistedSettings) {        replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings });      }      setClientSettingsHydrationStatus("ready");    } catch (error) {      if (hydrationGeneration === clientSettingsHydrationGeneration) {        setClientSettingsHydrationStatus("failed");      }      console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, {        operation: "hydrate",        ...safeErrorLogAttributes(error),      });      throw error;    }  })();   const hydrationPromise = nextHydration.finally(() => {    if (clientSettingsHydrationPromise === hydrationPromise) {      clientSettingsHydrationPromise = null;    }  });  clientSettingsHydrationPromise = hydrationPromise;   return clientSettingsHydrationPromise;} const defaultClientSettingsPersistence = (settings: ClientSettings): Promise<void> =>  ensureLocalApi().persistence.setClientSettings(settings); function enqueueClientSettingsPersistence<A>(work: () => Promise<A>): Promise<A> {  const result = clientSettingsPersistenceQueue.then(work);  clientSettingsPersistenceQueue = result.then(    () => undefined,    () => undefined,  );  return result;} export function persistClientSettingsPatch(  patch: ClientSettingsPatch,  persist: (settings: ClientSettings) => Promise<void> = defaultClientSettingsPersistence,): Promise<void> {  // Patches queued before hydration must publish before newer optimistic patches.  const deferPatch =    clientSettingsHydrationStatus !== "ready" || deferredClientSettingsPatchCount > 0;  if (deferPatch) {    deferredClientSettingsPatchCount += 1;  } else {    replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch });  }  return enqueueClientSettingsPersistence(async () => {    if (deferPatch) {      try {        if (clientSettingsHydrationStatus !== "ready") {          await hydrateClientSettings();        }        replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch });      } finally {        deferredClientSettingsPatchCount -= 1;      }    }    await persist(getClientSettingsSnapshot());  }).catch((error) => {    console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, {      operation: "persist",      ...safeErrorLogAttributes(error),    });  });} /** * Persists a client-settings update before publishing it to the in-memory * snapshot. If another settings write lands while persistence is pending, the * updater is reapplied to that newer snapshot and persisted again so neither * change is lost. */export async function persistClientSettingsUpdate(  update: (current: ClientSettings) => ClientSettings,  persist: (settings: ClientSettings) => Promise<void> = defaultClientSettingsPersistence,): Promise<ClientSettings> {  return enqueueClientSettingsPersistence(async () => {    if (clientSettingsHydrationStatus !== "ready") {      await hydrateClientSettings();    }    for (;;) {      const current = getClientSettingsSnapshot();      const next = update(current);      await persist(next);      if (getClientSettingsSnapshot() === current) {        replaceClientSettingsSnapshot(next);        return next;      }    }  });} // ── Key sets for routing patches ───────────────────────────────────── const SERVER_SETTINGS_KEYS = new Set<string>(Struct.keys(ServerSettings.fields)); function splitPatch(patch: UnifiedSettingsPatch): {  serverPatch: ServerSettingsPatch;  clientPatch: ClientSettingsPatch;} {  const serverPatch: Record<string, unknown> = {};  const clientPatch: Record<string, unknown> = {};  for (const [key, value] of Object.entries(patch)) {    if (SERVER_SETTINGS_KEYS.has(key)) {      serverPatch[key] = value;    } else {      clientPatch[key] = value;    }  }  return {    serverPatch: serverPatch as ServerSettingsPatch,    clientPatch: clientPatch as ClientSettingsPatch,  };} // ── Hooks ──────────────────────────────────────────────────────────── /** * Non-hook accessor for the current merged client settings snapshot. * Used by non-React code paths (e.g. runtime services) that need the latest * settings without subscribing. */export function getClientSettings(): ClientSettings {  return getClientSettingsSnapshot();} /** * Resolves after settings load or storage confirms no saved settings exist. * Failed reads reject and remain retryable. They must not allow defaults to * overwrite saved preferences. * * The pre-hydration snapshot is just the schema defaults, so imperative paths * that open a preview must await this or they bake the built-in viewport, zoom * and appearance into a tab that never picks up the user's saved values. */export function ensureClientSettingsHydrated(): Promise<void> {  return hydrateClientSettings();} export function useClientSettingsHydrated(): boolean {  return useSyncExternalStore(    subscribeClientSettingsHydration,    getClientSettingsHydratedSnapshot,    () => false,  );} export function useClientSettingsHydrationStatus(): ClientSettingsHydrationStatus {  return useSyncExternalStore(    subscribeClientSettingsHydration,    getClientSettingsHydrationStatusSnapshot,    () => "pending",  );} function useClientSettingsValue(): ClientSettings {  return useSyncExternalStore(    subscribeClientSettings,    getClientSettingsSnapshot,    () => DEFAULT_CLIENT_SETTINGS,  );} export function mergeEnvironmentSettings(  serverSettings: ServerSettings,  clientSettings: ClientSettings,): UnifiedSettings {  // Decode drops retired client keys, but older untyped persistence adapters  // can still return them. Server-owned values must always win.  return { ...clientSettings, ...serverSettings };} function useMergedSettings<T>(  serverSettings: ServerSettings,  selector: ((settings: UnifiedSettings) => T) | undefined,): T {  const clientSettings = useClientSettingsValue();   const merged = useMemo<UnifiedSettings>(    () => mergeEnvironmentSettings(serverSettings, clientSettings),    [clientSettings, serverSettings],  );   return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]);} export function useClientSettings<T = ClientSettings>(  selector?: (settings: ClientSettings) => T,): T {  const settings = useClientSettingsValue();  return useMemo(() => (selector ? selector(settings) : (settings as T)), [selector, settings]);} export function resolveEnvironmentIdentificationMode(input: {  mode: EnvironmentIdentificationMode;  settingsHydrated: boolean;  paletteThemeActive?: boolean;  paletteThemeAllowsArtwork?: boolean;}): EnvironmentIdentificationMode {  // Avoid briefly rendering the default artwork before a persisted pill/none choice loads.  if (!input.settingsHydrated) return "none";  // Artwork palettes are maintained for built-ins only. Keep an explicit  // "none", but use the theme-aware pill for user-controlled palettes.  return input.paletteThemeActive && !input.paletteThemeAllowsArtwork && input.mode === "artwork"    ? "pill"    : input.mode;} export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMode {  const settingsHydrated = useClientSettingsHydrated();  const mode = useClientSettingsValue().environmentIdentificationMode;  const { resolvedTheme, theme, themeHalves } = useTheme();  const previewSidebarArtwork = useSyncExternalStore(    subscribeToThemePreview,    getThemePreviewSidebarArtwork,    () => null,  );  const activeTheme = resolveThemeHalf(theme, themeHalves, resolvedTheme);  const activeThemeDefinition = getThemeDefinition(activeTheme);  return resolveEnvironmentIdentificationMode({    mode,    settingsHydrated,    paletteThemeActive: previewSidebarArtwork !== null || activeThemeDefinition !== null,    paletteThemeAllowsArtwork: previewSidebarArtwork ?? themeAllowsSidebarArtwork(activeTheme),  });} /** * Whether the legacy sidebar (Settings → General → Legacy features) replaces * the default one. * * Held at the default sidebar until client settings hydrate: the pre-hydration * snapshot is just the schema defaults, so resolving against it could mount one * sidebar and then swap it out once persisted settings land — remounting the * whole tree for everyone instead of only for legacy opt-ins. */export function useLegacySidebarEnabled(): boolean {  const settingsHydrated = useClientSettingsHydrated();  const legacySidebarEnabled = useClientSettingsValue().legacySidebarEnabled;  return settingsHydrated && legacySidebarEnabled;} /** Read current settings for one environment, merged with client-local preferences. */export function useEnvironmentSettings<T = UnifiedSettings>(  environmentId: EnvironmentId,  selector?: (settings: UnifiedSettings) => T,): T {  const serverSettings = useAtomValue(serverEnvironment.settingsValueAtom(environmentId));  return useMergedSettings(serverSettings ?? DEFAULT_SERVER_SETTINGS, selector);} /** Primary-only settings access for the settings UI and other explicitly global surfaces. */export function usePrimarySettings<T = UnifiedSettings>(  selector?: (settings: UnifiedSettings) => T,): T {  return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector);} export const PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE =  "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."; /** * Whether primary-scoped server settings have a server to live on. The * hosted app connects to every environment as a remote, so it has no primary: * `usePrimarySettings` reads schema defaults there and writes have nowhere * to go. Desktop and server-served web always have one. */export function usePrimarySettingsAvailable(): boolean {  const primaryEnvironment = usePrimaryEnvironment();  return primaryEnvironment !== null || !isHostedStaticApp();} /** * Returns an updater that routes each key to the correct backing store. * * Server keys are optimistically patched in atom-backed server state, then * persisted via RPC. Shared server keys (see `SHARED_SERVER_SETTING_KEYS`) * are written to every eligible sync target, not only the selected target, so * a user preference does not silently drift between machines. Client keys go * through client persistence. */function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {  const persistServerSettings = useAtomCommand(    serverEnvironment.updateSettings,    "server settings update",  );  const { environments } = useEnvironments();  const updateSettings = useCallback(    (patch: UnifiedSettingsPatch) => {      const { serverPatch, clientPatch } = splitPatch(patch);       if (Object.keys(serverPatch).length > 0) {        const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch);        // Dropping the write silently leaves the control looking saved.        const warnUnsaved = (description = PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE) =>          toastManager.add({            type: "warning",            title: "Setting not saved",            description,          });        if (Object.keys(localPatch).length > 0) {          if (environmentId) {            void persistServerSettings({              environmentId,              input: { patch: localPatch },            });          } else {            warnUnsaved();          }        }        if (Object.keys(sharedPatch).length > 0) {          const sourceSettings = environments.find(            (target) => target.environmentId === environmentId,          )?.serverConfig?.settings;          const targets = new Set(            environments.filter(supportsSharedSettingsSync).map((target) => target.environmentId),          );          if (environmentId) {            targets.add(environmentId);          }          let wroteToTarget = false;          for (const targetId of targets) {            const target = environments.find((candidate) => candidate.environmentId === targetId);            const targetPatch = filterSharedServerPatch(              sharedPatch,              target?.serverConfig?.environment.capabilities,              target?.serverConfig?.settings,              sourceSettings,              targetId === environmentId,            );            if (Object.keys(targetPatch).length === 0) continue;            wroteToTarget = true;            void persistServerSettings({              environmentId: targetId,              input: { patch: targetPatch },            });          }          if (!wroteToTarget) {            warnUnsaved(              targets.size > 0 ? "Update older servers to save this setting." : undefined,            );          }        }      }      if (Object.keys(clientPatch).length > 0) {        void persistClientSettingsPatch(clientPatch);      }    },    [environmentId, environments, persistServerSettings],  );   return updateSettings;} /** * Shared-settings sync targets whose values differ from the primary's, * plus an action that writes the primary's values to all of them. Drift * happens when an environment was offline during an edit or was changed by * an older client.

Lines 1–500 of 593.