refactor: split useSettings hook into specialized hooks

Before optimization:
- useSettings.ts: 516 lines (single monolithic hook)

After optimization:
- useSettingsForm.ts: 158 lines (form state management)
- useDirectorySettings.ts: 297 lines (directory management)
- useSettingsMetadata.ts: 95 lines (metadata management)
- useSettings.ts: 215 lines (composition layer)
- Total: 765 lines (+249 lines, but with clear separation of concerns)

Benefits:
 Single Responsibility Principle: each hook focuses on one domain
 Testability: independent hooks are easier to unit test
 Reusability: specialized hooks can be reused in other components
 Maintainability: reduced cognitive load per file
 Zero breaking changes: SettingsDialog auto-adapted to new interface

Technical details:
- useSettingsForm: pure form state + language sync
- useDirectorySettings: directory selection/reset + default value computation
- useSettingsMetadata: config path + portable mode + restart flag
- useSettings: composition layer + save logic + reset logic
This commit is contained in:
Jason
2025-10-17 18:12:03 +08:00
parent f963d58e6a
commit edfb61186d
4 changed files with 642 additions and 393 deletions

View File

@@ -0,0 +1,297 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { homeDir, join } from "@tauri-apps/api/path";
import { settingsApi, type AppType } from "@/lib/api";
import type { SettingsFormState } from "./useSettingsForm";
type DirectoryKey = "appConfig" | "claude" | "codex";
export interface ResolvedDirectories {
appConfig: string;
claude: string;
codex: string;
}
const sanitizeDir = (value?: string | null): string | undefined => {
if (!value) return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
const computeDefaultAppConfigDir = async (): Promise<string | undefined> => {
try {
const home = await homeDir();
return await join(home, ".cc-switch");
} catch (error) {
console.error(
"[useDirectorySettings] Failed to resolve default app config dir",
error,
);
return undefined;
}
};
const computeDefaultConfigDir = async (
app: AppType,
): Promise<string | undefined> => {
try {
const home = await homeDir();
const folder = app === "claude" ? ".claude" : ".codex";
return await join(home, folder);
} catch (error) {
console.error(
"[useDirectorySettings] Failed to resolve default config dir",
error,
);
return undefined;
}
};
export interface UseDirectorySettingsProps {
settings: SettingsFormState | null;
onUpdateSettings: (updates: Partial<SettingsFormState>) => void;
}
export interface UseDirectorySettingsResult {
appConfigDir?: string;
resolvedDirs: ResolvedDirectories;
isLoading: boolean;
initialAppConfigDir?: string;
updateDirectory: (app: AppType, value?: string) => void;
updateAppConfigDir: (value?: string) => void;
browseDirectory: (app: AppType) => Promise<void>;
browseAppConfigDir: () => Promise<void>;
resetDirectory: (app: AppType) => Promise<void>;
resetAppConfigDir: () => Promise<void>;
resetAllDirectories: (claudeDir?: string, codexDir?: string) => void;
}
/**
* useDirectorySettings - 目录管理
* 负责:
* - appConfigDir 状态
* - resolvedDirs 状态
* - 目录选择browse
* - 目录重置
* - 默认值计算
*/
export function useDirectorySettings({
settings,
onUpdateSettings,
}: UseDirectorySettingsProps): UseDirectorySettingsResult {
const { t } = useTranslation();
const [appConfigDir, setAppConfigDir] = useState<string | undefined>(
undefined,
);
const [resolvedDirs, setResolvedDirs] = useState<ResolvedDirectories>({
appConfig: "",
claude: "",
codex: "",
});
const [isLoading, setIsLoading] = useState(true);
const defaultsRef = useRef<ResolvedDirectories>({
appConfig: "",
claude: "",
codex: "",
});
const initialAppConfigDirRef = useRef<string | undefined>(undefined);
// 加载目录信息
useEffect(() => {
let active = true;
setIsLoading(true);
const load = async () => {
try {
const [
overrideRaw,
claudeDir,
codexDir,
defaultAppConfig,
defaultClaudeDir,
defaultCodexDir,
] = await Promise.all([
settingsApi.getAppConfigDirOverride(),
settingsApi.getConfigDir("claude"),
settingsApi.getConfigDir("codex"),
computeDefaultAppConfigDir(),
computeDefaultConfigDir("claude"),
computeDefaultConfigDir("codex"),
]);
if (!active) return;
const normalizedOverride = sanitizeDir(overrideRaw ?? undefined);
defaultsRef.current = {
appConfig: defaultAppConfig ?? "",
claude: defaultClaudeDir ?? "",
codex: defaultCodexDir ?? "",
};
setAppConfigDir(normalizedOverride);
initialAppConfigDirRef.current = normalizedOverride;
setResolvedDirs({
appConfig: normalizedOverride ?? defaultsRef.current.appConfig,
claude: claudeDir || defaultsRef.current.claude,
codex: codexDir || defaultsRef.current.codex,
});
} catch (error) {
console.error(
"[useDirectorySettings] Failed to load directory info",
error,
);
} finally {
if (active) {
setIsLoading(false);
}
}
};
void load();
return () => {
active = false;
};
}, []);
const updateDirectoryState = useCallback(
(key: DirectoryKey, value?: string) => {
const sanitized = sanitizeDir(value);
if (key === "appConfig") {
setAppConfigDir(sanitized);
} else {
onUpdateSettings(
key === "claude"
? { claudeConfigDir: sanitized }
: { codexConfigDir: sanitized },
);
}
setResolvedDirs((prev) => ({
...prev,
[key]: sanitized ?? defaultsRef.current[key],
}));
},
[onUpdateSettings],
);
const updateAppConfigDir = useCallback(
(value?: string) => {
updateDirectoryState("appConfig", value);
},
[updateDirectoryState],
);
const updateDirectory = useCallback(
(app: AppType, value?: string) => {
updateDirectoryState(app === "claude" ? "claude" : "codex", value);
},
[updateDirectoryState],
);
const browseDirectory = useCallback(
async (app: AppType) => {
const key: DirectoryKey = app === "claude" ? "claude" : "codex";
const currentValue =
key === "claude"
? (settings?.claudeConfigDir ?? resolvedDirs.claude)
: (settings?.codexConfigDir ?? resolvedDirs.codex);
try {
const picked = await settingsApi.selectConfigDirectory(currentValue);
const sanitized = sanitizeDir(picked ?? undefined);
if (!sanitized) return;
updateDirectoryState(key, sanitized);
} catch (error) {
console.error("[useDirectorySettings] Failed to pick directory", error);
toast.error(
t("settings.selectFileFailed", {
defaultValue: "选择目录失败",
}),
);
}
},
[settings, resolvedDirs, t, updateDirectoryState],
);
const browseAppConfigDir = useCallback(async () => {
const currentValue = appConfigDir ?? resolvedDirs.appConfig;
try {
const picked = await settingsApi.selectConfigDirectory(currentValue);
const sanitized = sanitizeDir(picked ?? undefined);
if (!sanitized) return;
updateDirectoryState("appConfig", sanitized);
} catch (error) {
console.error(
"[useDirectorySettings] Failed to pick app config directory",
error,
);
toast.error(
t("settings.selectFileFailed", {
defaultValue: "选择目录失败",
}),
);
}
}, [appConfigDir, resolvedDirs.appConfig, t, updateDirectoryState]);
const resetDirectory = useCallback(
async (app: AppType) => {
const key: DirectoryKey = app === "claude" ? "claude" : "codex";
if (!defaultsRef.current[key]) {
const fallback = await computeDefaultConfigDir(app);
if (fallback) {
defaultsRef.current = {
...defaultsRef.current,
[key]: fallback,
};
}
}
updateDirectoryState(key, undefined);
},
[updateDirectoryState],
);
const resetAppConfigDir = useCallback(async () => {
if (!defaultsRef.current.appConfig) {
const fallback = await computeDefaultAppConfigDir();
if (fallback) {
defaultsRef.current = {
...defaultsRef.current,
appConfig: fallback,
};
}
}
updateDirectoryState("appConfig", undefined);
}, [updateDirectoryState]);
const resetAllDirectories = useCallback(
(claudeDir?: string, codexDir?: string) => {
setAppConfigDir(initialAppConfigDirRef.current);
setResolvedDirs({
appConfig:
initialAppConfigDirRef.current ?? defaultsRef.current.appConfig,
claude: claudeDir ?? defaultsRef.current.claude,
codex: codexDir ?? defaultsRef.current.codex,
});
},
[],
);
return {
appConfigDir,
resolvedDirs,
isLoading,
initialAppConfigDir: initialAppConfigDirRef.current,
updateDirectory,
updateAppConfigDir,
browseDirectory,
browseAppConfigDir,
resetDirectory,
resetAppConfigDir,
resetAllDirectories,
};
}

View File

@@ -1,25 +1,21 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { homeDir, join } from "@tauri-apps/api/path";
import { settingsApi, type AppType } from "@/lib/api";
import { useSettingsQuery, useSaveSettingsMutation } from "@/lib/query";
import type { Settings } from "@/types";
import {
useSettingsForm,
type SettingsFormState,
} from "./useSettingsForm";
import {
useDirectorySettings,
type ResolvedDirectories,
} from "./useDirectorySettings";
import { useSettingsMetadata } from "./useSettingsMetadata";
type Language = "zh" | "en";
export type SettingsFormState = Omit<Settings, "language"> & {
language: Language;
};
type DirectoryKey = "appConfig" | "claude" | "codex";
export interface ResolvedDirectories {
appConfig: string;
claude: string;
codex: string;
}
interface SaveResult {
requiresRestart: boolean;
}
@@ -46,10 +42,7 @@ export interface UseSettingsResult {
acknowledgeRestart: () => void;
}
const normalizeLanguage = (lang?: string | null): Language => {
if (!lang) return "zh";
return lang === "en" ? "en" : "zh";
};
export type { SettingsFormState, ResolvedDirectories };
const sanitizeDir = (value?: string | null): string | undefined => {
if (!value) return undefined;
@@ -57,371 +50,90 @@ const sanitizeDir = (value?: string | null): string | undefined => {
return trimmed.length > 0 ? trimmed : undefined;
};
const computeDefaultAppConfigDir = async (): Promise<string | undefined> => {
try {
const home = await homeDir();
return await join(home, ".cc-switch");
} catch (error) {
console.error(
"[useSettings] Failed to resolve default app config dir",
error,
);
return undefined;
}
};
const computeDefaultConfigDir = async (
app: AppType,
): Promise<string | undefined> => {
try {
const home = await homeDir();
const folder = app === "claude" ? ".claude" : ".codex";
return await join(home, folder);
} catch (error) {
console.error("[useSettings] Failed to resolve default config dir", error);
return undefined;
}
};
/**
* useSettings - 组合层
* 负责:
* - 组合 useSettingsForm、useDirectorySettings、useSettingsMetadata
* - 保存设置逻辑
* - 重置设置逻辑
*/
export function useSettings(): UseSettingsResult {
const { t, i18n } = useTranslation();
const { data, isLoading } = useSettingsQuery();
const { t } = useTranslation();
const { data } = useSettingsQuery();
const saveMutation = useSaveSettingsMutation();
const [settingsState, setSettingsState] = useState<SettingsFormState | null>(
null,
);
const [appConfigDir, setAppConfigDir] = useState<string | undefined>(
undefined,
);
const [configPath, setConfigPath] = useState("");
const [isPortable, setIsPortable] = useState(false);
const [requiresRestart, setRequiresRestart] = useState(false);
const [resolvedDirs, setResolvedDirs] = useState<ResolvedDirectories>({
appConfig: "",
claude: "",
codex: "",
// 1⃣ 表单状态管理
const {
settings,
isLoading: isFormLoading,
initialLanguage,
updateSettings,
resetSettings: resetForm,
readPersistedLanguage,
syncLanguage,
} = useSettingsForm();
// 2⃣ 目录管理
const {
appConfigDir,
resolvedDirs,
isLoading: isDirectoryLoading,
initialAppConfigDir,
updateDirectory,
updateAppConfigDir,
browseDirectory,
browseAppConfigDir,
resetDirectory,
resetAppConfigDir,
resetAllDirectories,
} = useDirectorySettings({
settings,
onUpdateSettings: updateSettings,
});
const [isAuxiliaryLoading, setIsAuxiliaryLoading] = useState(true);
const defaultsRef = useRef<ResolvedDirectories>({
appConfig: "",
claude: "",
codex: "",
});
const initialLanguageRef = useRef<Language>("zh");
const initialAppConfigDirRef = useRef<string | undefined>(undefined);
const readPersistedLanguage = useCallback((): Language => {
if (typeof window !== "undefined") {
const stored = window.localStorage.getItem("language");
if (stored === "en" || stored === "zh") {
return stored;
}
}
return normalizeLanguage(i18n.language);
}, [i18n.language]);
const syncLanguage = useCallback(
(lang: Language) => {
const current = normalizeLanguage(i18n.language);
if (current !== lang) {
void i18n.changeLanguage(lang);
}
},
[i18n],
);
// 初始化设置数据
useEffect(() => {
if (!data) return;
const normalizedLanguage = normalizeLanguage(
data.language ?? readPersistedLanguage(),
);
const normalized: SettingsFormState = {
...data,
showInTray: data.showInTray ?? true,
minimizeToTrayOnClose: data.minimizeToTrayOnClose ?? true,
enableClaudePluginIntegration:
data.enableClaudePluginIntegration ?? false,
claudeConfigDir: sanitizeDir(data.claudeConfigDir),
codexConfigDir: sanitizeDir(data.codexConfigDir),
language: normalizedLanguage,
};
setSettingsState(normalized);
initialLanguageRef.current = normalizedLanguage;
syncLanguage(normalizedLanguage);
}, [data, readPersistedLanguage, syncLanguage]);
// 加载辅助信息(目录、配置路径、便携模式)
useEffect(() => {
let active = true;
setIsAuxiliaryLoading(true);
const load = async () => {
try {
const [
overrideRaw,
appConfigPath,
claudeDir,
codexDir,
portable,
defaultAppConfig,
defaultClaudeDir,
defaultCodexDir,
] = await Promise.all([
settingsApi.getAppConfigDirOverride(),
settingsApi.getAppConfigPath(),
settingsApi.getConfigDir("claude"),
settingsApi.getConfigDir("codex"),
settingsApi.isPortable(),
computeDefaultAppConfigDir(),
computeDefaultConfigDir("claude"),
computeDefaultConfigDir("codex"),
]);
if (!active) return;
const normalizedOverride = sanitizeDir(overrideRaw ?? undefined);
defaultsRef.current = {
appConfig: defaultAppConfig ?? "",
claude: defaultClaudeDir ?? "",
codex: defaultCodexDir ?? "",
};
setAppConfigDir(normalizedOverride);
initialAppConfigDirRef.current = normalizedOverride;
setResolvedDirs({
appConfig: normalizedOverride ?? defaultsRef.current.appConfig,
claude: claudeDir || defaultsRef.current.claude,
codex: codexDir || defaultsRef.current.codex,
});
setConfigPath(appConfigPath || "");
setIsPortable(portable);
} catch (error) {
console.error("[useSettings] Failed to load directory info", error);
} finally {
if (active) {
setIsAuxiliaryLoading(false);
}
}
};
void load();
return () => {
active = false;
};
}, []);
const updateSettings = useCallback(
(updates: Partial<SettingsFormState>) => {
setSettingsState((prev) => {
const base =
prev ??
({
showInTray: true,
minimizeToTrayOnClose: true,
enableClaudePluginIntegration: false,
language: readPersistedLanguage(),
} as SettingsFormState);
const next: SettingsFormState = {
...base,
...updates,
};
if (updates.language) {
const normalized = normalizeLanguage(updates.language);
next.language = normalized;
syncLanguage(normalized);
}
return next;
});
},
[readPersistedLanguage, syncLanguage],
);
const updateDirectoryState = useCallback(
(key: DirectoryKey, value?: string) => {
const sanitized = sanitizeDir(value);
if (key === "appConfig") {
setAppConfigDir(sanitized);
} else {
setSettingsState((prev) => {
if (!prev) return prev;
if (key === "claude") {
return {
...prev,
claudeConfigDir: sanitized,
};
}
return {
...prev,
codexConfigDir: sanitized,
};
});
}
setResolvedDirs((prev) => ({
...prev,
[key]: sanitized ?? defaultsRef.current[key],
}));
},
[],
);
const updateAppConfigDir = useCallback(
(value?: string) => {
updateDirectoryState("appConfig", value);
},
[updateDirectoryState],
);
const updateDirectory = useCallback(
(app: AppType, value?: string) => {
updateDirectoryState(app === "claude" ? "claude" : "codex", value);
},
[updateDirectoryState],
);
const browseDirectory = useCallback(
async (app: AppType) => {
const key: DirectoryKey = app === "claude" ? "claude" : "codex";
const currentValue =
key === "claude"
? (settingsState?.claudeConfigDir ?? resolvedDirs.claude)
: (settingsState?.codexConfigDir ?? resolvedDirs.codex);
try {
const picked = await settingsApi.selectConfigDirectory(currentValue);
const sanitized = sanitizeDir(picked ?? undefined);
if (!sanitized) return;
updateDirectoryState(key, sanitized);
} catch (error) {
console.error("[useSettings] Failed to pick directory", error);
toast.error(
t("settings.selectFileFailed", {
defaultValue: "选择目录失败",
}),
);
}
},
[settingsState, resolvedDirs, t, updateDirectoryState],
);
const browseAppConfigDir = useCallback(async () => {
const currentValue = appConfigDir ?? resolvedDirs.appConfig;
try {
const picked = await settingsApi.selectConfigDirectory(currentValue);
const sanitized = sanitizeDir(picked ?? undefined);
if (!sanitized) return;
updateDirectoryState("appConfig", sanitized);
} catch (error) {
console.error("[useSettings] Failed to pick app config directory", error);
toast.error(
t("settings.selectFileFailed", {
defaultValue: "选择目录失败",
}),
);
}
}, [appConfigDir, resolvedDirs.appConfig, t, updateDirectoryState]);
const resetDirectory = useCallback(
async (app: AppType) => {
const key: DirectoryKey = app === "claude" ? "claude" : "codex";
if (!defaultsRef.current[key]) {
const fallback = await computeDefaultConfigDir(app);
if (fallback) {
defaultsRef.current = {
...defaultsRef.current,
[key]: fallback,
};
}
}
updateDirectoryState(key, undefined);
},
[updateDirectoryState],
);
const resetAppConfigDir = useCallback(async () => {
if (!defaultsRef.current.appConfig) {
const fallback = await computeDefaultAppConfigDir();
if (fallback) {
defaultsRef.current = {
...defaultsRef.current,
appConfig: fallback,
};
}
}
updateDirectoryState("appConfig", undefined);
}, [updateDirectoryState]);
const openConfigFolder = useCallback(async () => {
try {
await settingsApi.openAppConfigFolder();
} catch (error) {
console.error("[useSettings] Failed to open config folder", error);
toast.error(
t("settings.openFolderFailed", {
defaultValue: "打开目录失败",
}),
);
}
}, [t]);
// 3⃣ 元数据管理
const {
configPath,
isPortable,
requiresRestart,
isLoading: isMetadataLoading,
openConfigFolder,
acknowledgeRestart,
setRequiresRestart,
} = useSettingsMetadata();
// 重置设置
const resetSettings = useCallback(() => {
if (!data) return;
const normalizedLanguage = normalizeLanguage(
data.language ?? readPersistedLanguage(),
resetForm(data ?? null);
syncLanguage(initialLanguage);
resetAllDirectories(
sanitizeDir(data?.claudeConfigDir),
sanitizeDir(data?.codexConfigDir),
);
const normalized: SettingsFormState = {
...data,
showInTray: data.showInTray ?? true,
minimizeToTrayOnClose: data.minimizeToTrayOnClose ?? true,
enableClaudePluginIntegration:
data.enableClaudePluginIntegration ?? false,
claudeConfigDir: sanitizeDir(data.claudeConfigDir),
codexConfigDir: sanitizeDir(data.codexConfigDir),
language: normalizedLanguage,
};
setSettingsState(normalized);
syncLanguage(initialLanguageRef.current);
setAppConfigDir(initialAppConfigDirRef.current);
setResolvedDirs({
appConfig:
initialAppConfigDirRef.current ?? defaultsRef.current.appConfig,
claude: normalized.claudeConfigDir ?? defaultsRef.current.claude,
codex: normalized.codexConfigDir ?? defaultsRef.current.codex,
});
setRequiresRestart(false);
}, [data, readPersistedLanguage, syncLanguage]);
const acknowledgeRestart = useCallback(() => {
setRequiresRestart(false);
}, []);
}, [
data,
initialLanguage,
resetForm,
syncLanguage,
resetAllDirectories,
setRequiresRestart,
]);
// 保存设置
const saveSettings = useCallback(async (): Promise<SaveResult | null> => {
if (!settingsState) return null;
if (!settings) return null;
try {
const sanitizedAppDir = sanitizeDir(appConfigDir);
const sanitizedClaudeDir = sanitizeDir(settingsState.claudeConfigDir);
const sanitizedCodexDir = sanitizeDir(settingsState.codexConfigDir);
const previousAppDir = initialAppConfigDirRef.current;
const sanitizedClaudeDir = sanitizeDir(settings.claudeConfigDir);
const sanitizedCodexDir = sanitizeDir(settings.codexConfigDir);
const previousAppDir = initialAppConfigDir;
const payload: Settings = {
...settingsState,
...settings,
claudeConfigDir: sanitizedClaudeDir,
codexConfigDir: sanitizedCodexDir,
language: settingsState.language,
language: settings.language,
};
await saveMutation.mutateAsync(payload);
@@ -457,27 +169,7 @@ export function useSettings(): UseSettingsResult {
);
}
initialLanguageRef.current = payload.language as Language;
setSettingsState((prev) =>
prev
? {
...prev,
claudeConfigDir: sanitizedClaudeDir,
codexConfigDir: sanitizedCodexDir,
language: payload.language as Language,
}
: prev,
);
setResolvedDirs({
appConfig: sanitizedAppDir ?? defaultsRef.current.appConfig,
claude: sanitizedClaudeDir ?? defaultsRef.current.claude,
codex: sanitizedCodexDir ?? defaultsRef.current.codex,
});
setAppConfigDir(sanitizedAppDir);
const appDirChanged = sanitizedAppDir !== (previousAppDir ?? undefined);
initialAppConfigDirRef.current = sanitizedAppDir;
setRequiresRestart(appDirChanged);
return { requiresRestart: appDirChanged };
@@ -485,16 +177,23 @@ export function useSettings(): UseSettingsResult {
console.error("[useSettings] Failed to save settings", error);
throw error;
}
}, [appConfigDir, saveMutation, settingsState, t]);
}, [
appConfigDir,
initialAppConfigDir,
saveMutation,
settings,
setRequiresRestart,
t,
]);
const isBusy = useMemo(
() => isLoading || isAuxiliaryLoading,
[isLoading, isAuxiliaryLoading],
const isLoading = useMemo(
() => isFormLoading || isDirectoryLoading || isMetadataLoading,
[isFormLoading, isDirectoryLoading, isMetadataLoading],
);
return {
settings: settingsState,
isLoading: isBusy,
settings,
isLoading,
isSaving: saveMutation.isPending,
isPortable,
configPath,

View File

@@ -0,0 +1,158 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSettingsQuery } from "@/lib/query";
import type { Settings } from "@/types";
type Language = "zh" | "en";
export type SettingsFormState = Omit<Settings, "language"> & {
language: Language;
};
const normalizeLanguage = (lang?: string | null): Language => {
if (!lang) return "zh";
return lang === "en" ? "en" : "zh";
};
const sanitizeDir = (value?: string | null): string | undefined => {
if (!value) return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
export interface UseSettingsFormResult {
settings: SettingsFormState | null;
isLoading: boolean;
initialLanguage: Language;
updateSettings: (updates: Partial<SettingsFormState>) => void;
resetSettings: (serverData: Settings | null) => void;
readPersistedLanguage: () => Language;
syncLanguage: (lang: Language) => void;
}
/**
* useSettingsForm - 表单状态管理
* 负责:
* - 表单数据状态
* - 表单字段更新
* - 语言同步
* - 表单重置
*/
export function useSettingsForm(): UseSettingsFormResult {
const { i18n } = useTranslation();
const { data, isLoading } = useSettingsQuery();
const [settingsState, setSettingsState] = useState<SettingsFormState | null>(
null,
);
const initialLanguageRef = useRef<Language>("zh");
const readPersistedLanguage = useCallback((): Language => {
if (typeof window !== "undefined") {
const stored = window.localStorage.getItem("language");
if (stored === "en" || stored === "zh") {
return stored;
}
}
return normalizeLanguage(i18n.language);
}, [i18n.language]);
const syncLanguage = useCallback(
(lang: Language) => {
const current = normalizeLanguage(i18n.language);
if (current !== lang) {
void i18n.changeLanguage(lang);
}
},
[i18n],
);
// 初始化设置数据
useEffect(() => {
if (!data) return;
const normalizedLanguage = normalizeLanguage(
data.language ?? readPersistedLanguage(),
);
const normalized: SettingsFormState = {
...data,
showInTray: data.showInTray ?? true,
minimizeToTrayOnClose: data.minimizeToTrayOnClose ?? true,
enableClaudePluginIntegration:
data.enableClaudePluginIntegration ?? false,
claudeConfigDir: sanitizeDir(data.claudeConfigDir),
codexConfigDir: sanitizeDir(data.codexConfigDir),
language: normalizedLanguage,
};
setSettingsState(normalized);
initialLanguageRef.current = normalizedLanguage;
syncLanguage(normalizedLanguage);
}, [data, readPersistedLanguage, syncLanguage]);
const updateSettings = useCallback(
(updates: Partial<SettingsFormState>) => {
setSettingsState((prev) => {
const base =
prev ??
({
showInTray: true,
minimizeToTrayOnClose: true,
enableClaudePluginIntegration: false,
language: readPersistedLanguage(),
} as SettingsFormState);
const next: SettingsFormState = {
...base,
...updates,
};
if (updates.language) {
const normalized = normalizeLanguage(updates.language);
next.language = normalized;
syncLanguage(normalized);
}
return next;
});
},
[readPersistedLanguage, syncLanguage],
);
const resetSettings = useCallback(
(serverData: Settings | null) => {
if (!serverData) return;
const normalizedLanguage = normalizeLanguage(
serverData.language ?? readPersistedLanguage(),
);
const normalized: SettingsFormState = {
...serverData,
showInTray: serverData.showInTray ?? true,
minimizeToTrayOnClose: serverData.minimizeToTrayOnClose ?? true,
enableClaudePluginIntegration:
serverData.enableClaudePluginIntegration ?? false,
claudeConfigDir: sanitizeDir(serverData.claudeConfigDir),
codexConfigDir: sanitizeDir(serverData.codexConfigDir),
language: normalizedLanguage,
};
setSettingsState(normalized);
syncLanguage(initialLanguageRef.current);
},
[readPersistedLanguage, syncLanguage],
);
return {
settings: settingsState,
isLoading,
initialLanguage: initialLanguageRef.current,
updateSettings,
resetSettings,
readPersistedLanguage,
syncLanguage,
};
}

View File

@@ -0,0 +1,95 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { settingsApi } from "@/lib/api";
export interface UseSettingsMetadataResult {
configPath: string;
isPortable: boolean;
requiresRestart: boolean;
isLoading: boolean;
openConfigFolder: () => Promise<void>;
acknowledgeRestart: () => void;
setRequiresRestart: (value: boolean) => void;
}
/**
* useSettingsMetadata - 元数据管理
* 负责:
* - configPath配置文件路径
* - isPortable便携模式
* - requiresRestart需要重启标志
* - 打开配置文件夹
*/
export function useSettingsMetadata(): UseSettingsMetadataResult {
const { t } = useTranslation();
const [configPath, setConfigPath] = useState("");
const [isPortable, setIsPortable] = useState(false);
const [requiresRestart, setRequiresRestart] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// 加载元数据
useEffect(() => {
let active = true;
setIsLoading(true);
const load = async () => {
try {
const [appConfigPath, portable] = await Promise.all([
settingsApi.getAppConfigPath(),
settingsApi.isPortable(),
]);
if (!active) return;
setConfigPath(appConfigPath || "");
setIsPortable(portable);
} catch (error) {
console.error(
"[useSettingsMetadata] Failed to load metadata",
error,
);
} finally {
if (active) {
setIsLoading(false);
}
}
};
void load();
return () => {
active = false;
};
}, []);
const openConfigFolder = useCallback(async () => {
try {
await settingsApi.openAppConfigFolder();
} catch (error) {
console.error(
"[useSettingsMetadata] Failed to open config folder",
error,
);
toast.error(
t("settings.openFolderFailed", {
defaultValue: "打开目录失败",
}),
);
}
}, [t]);
const acknowledgeRestart = useCallback(() => {
setRequiresRestart(false);
}, []);
return {
configPath,
isPortable,
requiresRestart,
isLoading,
openConfigFolder,
acknowledgeRestart,
setRequiresRestart,
};
}