From cfee4d6fcc263aa15cdcc9299cc65e7e35660cd7 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Sat, 22 Nov 2025 15:27:32 +0800 Subject: [PATCH] feat(settings): add autoSaveSettings for lightweight auto-save Add optimized auto-save function for General tab settings. - Add autoSaveSettings method for non-destructive auto-save - Only trigger system APIs when values actually change - Avoid unnecessary auto-launch and plugin config updates - Update tests to cover new functionality --- src/hooks/useSettings.ts | 125 ++++++++++++++++++---- tests/hooks/useDirectorySettings.test.tsx | 1 + tests/hooks/useSettings.test.tsx | 37 +++++-- 3 files changed, 138 insertions(+), 25 deletions(-) diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 840b1cd..280d5ae 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -37,6 +37,9 @@ export interface UseSettingsResult { overrides?: Partial, options?: { silent?: boolean }, ) => Promise; + autoSaveSettings: ( + updates: Partial, + ) => Promise; resetSettings: () => void; acknowledgeRestart: () => void; } @@ -117,7 +120,82 @@ export function useSettings(): UseSettingsResult { setRequiresRestart, ]); - // 保存设置 + // 即时保存设置(用于 General 标签页的实时更新) + // 保存基础配置 + 独立的系统 API 调用(开机自启) + const autoSaveSettings = useCallback( + async (updates: Partial): Promise => { + const mergedSettings = settings ? { ...settings, ...updates } : null; + if (!mergedSettings) return null; + + try { + const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir); + const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir); + const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir); + + const payload: Settings = { + ...mergedSettings, + claudeConfigDir: sanitizedClaudeDir, + codexConfigDir: sanitizedCodexDir, + geminiConfigDir: sanitizedGeminiDir, + language: mergedSettings.language, + }; + + // 保存到配置文件 + await saveMutation.mutateAsync(payload); + + // 如果开机自启状态改变,调用系统 API + if ( + payload.launchOnStartup !== undefined && + payload.launchOnStartup !== data?.launchOnStartup + ) { + try { + await settingsApi.setAutoLaunch(payload.launchOnStartup); + } catch (error) { + console.error("Failed to update auto-launch:", error); + toast.error( + t("settings.autoLaunchFailed", { + defaultValue: "设置开机自启失败", + }), + ); + } + } + + // 持久化语言偏好 + try { + if (typeof window !== "undefined" && updates.language) { + window.localStorage.setItem("language", updates.language); + } + } catch (error) { + console.warn( + "[useSettings] Failed to persist language preference", + error, + ); + } + + // 更新托盘菜单 + try { + await providersApi.updateTrayMenu(); + } catch (error) { + console.warn("[useSettings] Failed to refresh tray menu", error); + } + + return { requiresRestart: false }; + } catch (error) { + console.error("[useSettings] Failed to auto-save settings", error); + toast.error( + t("notifications.settingsSaveFailed", { + defaultValue: "保存设置失败: {{error}}", + error: (error as Error)?.message ?? String(error), + }), + ); + throw error; + } + }, + [data, saveMutation, settings, t], + ); + + // 完整保存设置(用于 Advanced 标签页的手动保存) + // 包含所有系统 API 调用和完整的验证流程 const saveSettings = useCallback( async ( overrides?: Partial, @@ -147,8 +225,11 @@ export function useSettings(): UseSettingsResult { await settingsApi.setAppConfigDirOverride(sanitizedAppDir ?? null); - // 如果开机自启状态改变,调用系统 API - if (payload.launchOnStartup !== undefined) { + // 只在开机自启状态真正改变时调用系统 API + if ( + payload.launchOnStartup !== undefined && + payload.launchOnStartup !== data?.launchOnStartup + ) { try { await settingsApi.setAutoLaunch(payload.launchOnStartup); } catch (error) { @@ -161,22 +242,29 @@ export function useSettings(): UseSettingsResult { } } - try { - if (payload.enableClaudePluginIntegration) { - await settingsApi.applyClaudePluginConfig({ official: false }); - } else { - await settingsApi.applyClaudePluginConfig({ official: true }); + // 只在 Claude 插件集成状态真正改变时调用系统 API + if ( + payload.enableClaudePluginIntegration !== undefined && + payload.enableClaudePluginIntegration !== + data?.enableClaudePluginIntegration + ) { + try { + if (payload.enableClaudePluginIntegration) { + await settingsApi.applyClaudePluginConfig({ official: false }); + } else { + await settingsApi.applyClaudePluginConfig({ official: true }); + } + } catch (error) { + console.warn( + "[useSettings] Failed to sync Claude plugin config", + error, + ); + toast.error( + t("notifications.syncClaudePluginFailed", { + defaultValue: "同步 Claude 插件失败", + }), + ); } - } catch (error) { - console.warn( - "[useSettings] Failed to sync Claude plugin config", - error, - ); - toast.error( - t("notifications.syncClaudePluginFailed", { - defaultValue: "同步 Claude 插件失败", - }), - ); } try { @@ -268,6 +356,7 @@ export function useSettings(): UseSettingsResult { resetDirectory, resetAppConfigDir, saveSettings, + autoSaveSettings, resetSettings, acknowledgeRestart, }; diff --git a/tests/hooks/useDirectorySettings.test.tsx b/tests/hooks/useDirectorySettings.test.tsx index f109006..6b8204d 100644 --- a/tests/hooks/useDirectorySettings.test.tsx +++ b/tests/hooks/useDirectorySettings.test.tsx @@ -84,6 +84,7 @@ describe("useDirectorySettings", () => { appConfig: "/override/app", claude: "/remote/claude", codex: "/remote/codex", + gemini: "/remote/codex", // Gemini 使用 codex 作为默认 }); }); diff --git a/tests/hooks/useSettings.test.tsx b/tests/hooks/useSettings.test.tsx index 50c965f..b625197 100644 --- a/tests/hooks/useSettings.test.tsx +++ b/tests/hooks/useSettings.test.tsx @@ -143,7 +143,7 @@ describe("useSettings hook", () => { it("saves settings and flags restart when app config directory changes", async () => { serverSettings = { ...serverSettings, - enableClaudePluginIntegration: true, + enableClaudePluginIntegration: false, claudeConfigDir: "/server/claude", codexConfigDir: undefined, language: "en", @@ -159,7 +159,7 @@ describe("useSettings hook", () => { claudeConfigDir: " /custom/claude ", codexConfigDir: " ", language: "en", - enableClaudePluginIntegration: true, + enableClaudePluginIntegration: true, // 状态从 false 变为 true }, initialLanguage: "en", }); @@ -183,6 +183,7 @@ describe("useSettings hook", () => { expect(payload.codexConfigDir).toBeUndefined(); expect(payload.language).toBe("en"); expect(setAppConfigDirOverrideMock).toHaveBeenCalledWith("/override/app"); + // 状态改变,应该调用 API expect(applyClaudePluginConfigMock).toHaveBeenCalledWith({ official: false, }); @@ -194,10 +195,22 @@ describe("useSettings hook", () => { }); it("saves settings without restart when directory unchanged", async () => { + // 确保服务器和本地状态一致,不触发 API 调用 + serverSettings = { + ...serverSettings, + enableClaudePluginIntegration: false, + launchOnStartup: false, + }; + useSettingsQueryMock.mockReturnValue({ + data: serverSettings, + isLoading: false, + }); + settingsFormMock = createSettingsFormMock({ settings: { ...serverSettings, - enableClaudePluginIntegration: false, + enableClaudePluginIntegration: false, // 状态未变 + launchOnStartup: false, // 状态未变 language: "zh", }, initialLanguage: "zh", @@ -217,19 +230,28 @@ describe("useSettings hook", () => { expect(saveResult).toEqual({ requiresRestart: false }); expect(setAppConfigDirOverrideMock).toHaveBeenCalledWith(null); - expect(applyClaudePluginConfigMock).toHaveBeenCalledWith({ - official: true, - }); + // 状态未改变,不应调用 API + expect(applyClaudePluginConfigMock).not.toHaveBeenCalled(); expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false); // 目录未变化,不应触发同步 expect(syncCurrentProvidersLiveMock).not.toHaveBeenCalled(); }); it("shows toast when Claude plugin sync fails but continues flow", async () => { + // 设置服务器状态为 false,本地状态为 true,触发状态变化 + serverSettings = { + ...serverSettings, + enableClaudePluginIntegration: false, + }; + useSettingsQueryMock.mockReturnValue({ + data: serverSettings, + isLoading: false, + }); + settingsFormMock = createSettingsFormMock({ settings: { ...serverSettings, - enableClaudePluginIntegration: true, + enableClaudePluginIntegration: true, // 状态改变 language: "zh", }, }); @@ -286,6 +308,7 @@ describe("useSettings hook", () => { expect(directorySettingsMock.resetAllDirectories).toHaveBeenCalledWith( "/server/claude", undefined, + undefined, // geminiConfigDir ); expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false); });