262 lines · 8.5 KB · TypeScript
RawDownload
/** * Unified settings hook. * * 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). * * Consumers use `useSettings(selector)` to read, and `useUpdateSettings()` to * write. The hook transparently routes reads/writes to the correct backing * store. */import { useCallback, useMemo } from "react";import {  ServerSettings,  ServerSettingsPatch,  ModelSelection,  ThreadEnvMode,} from "@t3tools/contracts";import {  type ClientSettings,  ClientSettingsSchema,  DEFAULT_CLIENT_SETTINGS,  DEFAULT_UNIFIED_SETTINGS,  SidebarProjectSortOrder,  SidebarThreadSortOrder,  TimestampFormat,  UnifiedSettings,} from "@t3tools/contracts/settings";import { ensureNativeApi } from "~/nativeApi";import { useLocalStorage } from "./useLocalStorage";import { normalizeCustomModelSlugs } from "~/modelSelection";import { Predicate, Schema, Struct } from "effect";import { DeepMutable } from "effect/Types";import { deepMerge } from "@t3tools/shared/Struct";import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState"; const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1";const OLD_SETTINGS_KEY = "t3code:app-settings:v1"; // ── Key sets for routing patches ───────────────────────────────────── const SERVER_SETTINGS_KEYS = new Set<string>(Struct.keys(ServerSettings.fields)); function splitPatch(patch: Partial<UnifiedSettings>): {  serverPatch: ServerSettingsPatch;  clientPatch: Partial<ClientSettings>;} {  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 Partial<ClientSettings>,  };} // ── Hooks ──────────────────────────────────────────────────────────── /** * Read merged settings. Selector narrows the subscription so components * only re-render when the slice they care about changes. */ export function useSettings<T extends UnifiedSettings = UnifiedSettings>(  selector?: (s: UnifiedSettings) => T,): T {  const serverSettings = useServerSettings();  const [clientSettings] = useLocalStorage(    CLIENT_SETTINGS_STORAGE_KEY,    DEFAULT_CLIENT_SETTINGS,    ClientSettingsSchema,  );   const merged = useMemo<UnifiedSettings>(    () => ({      ...serverSettings,      ...clientSettings,    }),    [clientSettings, serverSettings],  );   return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]);} /** * 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. Client keys go straight to localStorage. */export function useUpdateSettings() {  const [, setClientSettings] = useLocalStorage(    CLIENT_SETTINGS_STORAGE_KEY,    DEFAULT_CLIENT_SETTINGS,    ClientSettingsSchema,  );   const updateSettings = useCallback(    (patch: Partial<UnifiedSettings>) => {      const { serverPatch, clientPatch } = splitPatch(patch);       if (Object.keys(serverPatch).length > 0) {        const currentServerConfig = getServerConfig();        if (currentServerConfig) {          applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch));        }        // Fire-and-forget RPC — push will reconcile on success        void ensureNativeApi().server.updateSettings(serverPatch);      }       if (Object.keys(clientPatch).length > 0) {        setClientSettings((prev) => ({ ...prev, ...clientPatch }));      }    },    [setClientSettings],  );   const resetSettings = useCallback(() => {    updateSettings(DEFAULT_UNIFIED_SETTINGS);  }, [updateSettings]);   return {    updateSettings,    resetSettings,  };} // ── One-time migration from localStorage ───────────────────────────── export function buildLegacyServerSettingsMigrationPatch(legacySettings: Record<string, unknown>) {  const patch: DeepMutable<ServerSettingsPatch> = {};   if (Predicate.isBoolean(legacySettings.enableAssistantStreaming)) {    patch.enableAssistantStreaming = legacySettings.enableAssistantStreaming;  }   if (Schema.is(ThreadEnvMode)(legacySettings.defaultThreadEnvMode)) {    patch.defaultThreadEnvMode = legacySettings.defaultThreadEnvMode;  }   if (Schema.is(ModelSelection)(legacySettings.textGenerationModelSelection)) {    patch.textGenerationModelSelection = legacySettings.textGenerationModelSelection;  }   if (typeof legacySettings.codexBinaryPath === "string") {    patch.providers ??= {};    patch.providers.codex ??= {};    patch.providers.codex.binaryPath = legacySettings.codexBinaryPath;  }   if (typeof legacySettings.codexHomePath === "string") {    patch.providers ??= {};    patch.providers.codex ??= {};    patch.providers.codex.homePath = legacySettings.codexHomePath;  }   if (Array.isArray(legacySettings.customCodexModels)) {    patch.providers ??= {};    patch.providers.codex ??= {};    patch.providers.codex.customModels = normalizeCustomModelSlugs(      legacySettings.customCodexModels,      new Set<string>(),      "codex",    );  }   if (Predicate.isString(legacySettings.claudeBinaryPath)) {    patch.providers ??= {};    patch.providers.claudeAgent ??= {};    patch.providers.claudeAgent.binaryPath = legacySettings.claudeBinaryPath;  }   if (Array.isArray(legacySettings.customClaudeModels)) {    patch.providers ??= {};    patch.providers.claudeAgent ??= {};    patch.providers.claudeAgent.customModels = normalizeCustomModelSlugs(      legacySettings.customClaudeModels,      new Set<string>(),      "claudeAgent",    );  }   return patch;} export function buildLegacyClientSettingsMigrationPatch(  legacySettings: Record<string, unknown>,): Partial<DeepMutable<ClientSettings>> {  const patch: Partial<DeepMutable<ClientSettings>> = {};   if (Predicate.isBoolean(legacySettings.confirmThreadArchive)) {    patch.confirmThreadArchive = legacySettings.confirmThreadArchive;  }   if (Predicate.isBoolean(legacySettings.confirmThreadDelete)) {    patch.confirmThreadDelete = legacySettings.confirmThreadDelete;  }   if (Predicate.isBoolean(legacySettings.diffWordWrap)) {    patch.diffWordWrap = legacySettings.diffWordWrap;  }   if (Schema.is(SidebarProjectSortOrder)(legacySettings.sidebarProjectSortOrder)) {    patch.sidebarProjectSortOrder = legacySettings.sidebarProjectSortOrder;  }   if (Schema.is(SidebarThreadSortOrder)(legacySettings.sidebarThreadSortOrder)) {    patch.sidebarThreadSortOrder = legacySettings.sidebarThreadSortOrder;  }   if (Schema.is(TimestampFormat)(legacySettings.timestampFormat)) {    patch.timestampFormat = legacySettings.timestampFormat;  }   return patch;} /** * Call once on app startup. * If the legacy localStorage key exists, migrate its values to the new server * and client storage formats, then remove the legacy key so this only runs once. */export function migrateLocalSettingsToServer(): void {  if (typeof window === "undefined") return;   const raw = localStorage.getItem(OLD_SETTINGS_KEY);  if (!raw) return;   try {    const old = JSON.parse(raw);    if (!Predicate.isObject(old)) return;     // Migrate server-relevant keys via RPC    const serverPatch = buildLegacyServerSettingsMigrationPatch(old);    if (Object.keys(serverPatch).length > 0) {      const api = ensureNativeApi();      void api.server.updateSettings(serverPatch);    }     // Migrate client-only keys to the new localStorage key    const clientPatch = buildLegacyClientSettingsMigrationPatch(old);    if (Object.keys(clientPatch).length > 0) {      const existing = localStorage.getItem(CLIENT_SETTINGS_STORAGE_KEY);      const current = existing ? (JSON.parse(existing) as Record<string, unknown>) : {};      localStorage.setItem(        CLIENT_SETTINGS_STORAGE_KEY,        JSON.stringify({ ...current, ...clientPatch }),      );    }  } catch (error) {    console.error("[MIGRATION] Error migrating local settings:", error);  } finally {    // Remove the legacy key regardless to keep migration one-shot behavior.    localStorage.removeItem(OLD_SETTINGS_KEY);  }}