From b994f5f114dea5b8eb9d3910fcb53bbc5eb9d1cc Mon Sep 17 00:00:00 2001 From: chengww Date: Sun, 5 Apr 2026 17:06:41 +0800 Subject: [PATCH] fix(claude-plugin): sync current provider config to settings.json on toggle enable - Extract syncClaudePluginIfChanged to share logic between autoSaveSettings and saveSettings - Fix P1: enableClaudePluginIntegration toggle in General tab now actually syncs ~/.claude/settings.json - Fix P2: check syncCurrentProvidersLiveSafe() return value and show toast on failure - Fix P3: sync providers on both enable and disable, not just enable - Fix P4: avoid double syncCurrentProvidersLiveSafe when plugin toggle + dir change happen together - Remove duplicate comment - Add missing providersApi.getCurrent/getAll mocks in tests --- src/hooks/useSettings.ts | 93 ++++++++++++++++++++++---------- tests/hooks/useSettings.test.tsx | 11 +++- 2 files changed, 74 insertions(+), 30 deletions(-) diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index b24e01e43..af5e16658 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -121,6 +121,57 @@ export function useSettings(): UseSettingsResult { setRequiresRestart, ]); + // 同步 Claude 插件集成配置到 ~/.claude/settings.json + // 返回 true 表示已执行过 syncCurrentProvidersLiveSafe,调用方可跳过重复同步 + const syncClaudePluginIfChanged = useCallback( + async (enabled: boolean | undefined): Promise => { + if ( + enabled === undefined || + enabled === data?.enableClaudePluginIntegration + ) + return false; + try { + if (enabled) { + const currentId = await providersApi.getCurrent("claude"); + let isOfficial = false; + if (currentId) { + const allProviders = await providersApi.getAll("claude"); + isOfficial = allProviders[currentId]?.category === "official"; + } + await settingsApi.applyClaudePluginConfig({ official: isOfficial }); + } else { + await settingsApi.applyClaudePluginConfig({ official: true }); + } + + const syncResult = await syncCurrentProvidersLiveSafe(); + if (!syncResult.ok) { + console.warn( + "[useSettings] Failed to sync providers after toggling Claude plugin", + syncResult.error, + ); + toast.error( + t("notifications.syncClaudePluginFailed", { + defaultValue: "同步 Claude 插件失败", + }), + ); + } + return true; + } catch (error) { + console.warn( + "[useSettings] Failed to sync Claude plugin config", + error, + ); + toast.error( + t("notifications.syncClaudePluginFailed", { + defaultValue: "同步 Claude 插件失败", + }), + ); + return false; + } + }, + [data?.enableClaudePluginIntegration, t], + ); + // 即时保存设置(用于 General 标签页的实时更新) // 保存基础配置 + 独立的系统 API 调用(开机自启) const autoSaveSettings = useCallback( @@ -197,6 +248,8 @@ export function useSettings(): UseSettingsResult { } } + await syncClaudePluginIfChanged(payload.enableClaudePluginIntegration); + // 持久化语言偏好 try { if (typeof window !== "undefined" && updates.language) { @@ -228,7 +281,7 @@ export function useSettings(): UseSettingsResult { throw error; } }, - [data, saveMutation, settings, t], + [data, saveMutation, settings, syncClaudePluginIfChanged, t], ); // 完整保存设置(用于 Advanced 标签页的手动保存) @@ -313,30 +366,9 @@ export function useSettings(): UseSettingsResult { } } - // 只在 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 插件失败", - }), - ); - } - } + const pluginSynced = await syncClaudePluginIfChanged( + payload.enableClaudePluginIntegration, + ); try { if (typeof window !== "undefined") { @@ -359,15 +391,17 @@ export function useSettings(): UseSettingsResult { } // 如果 Claude/Codex/Gemini/OpenCode 的目录覆盖发生变化,则立即将"当前使用的供应商"写回对应应用的 live 配置 + // 如果插件同步已经执行过 syncCurrentProvidersLiveSafe,则跳过避免重复 const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir; const codexDirChanged = sanitizedCodexDir !== previousCodexDir; const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir; const opencodeDirChanged = sanitizedOpencodeDir !== previousOpencodeDir; if ( - claudeDirChanged || - codexDirChanged || - geminiDirChanged || - opencodeDirChanged + !pluginSynced && + (claudeDirChanged || + codexDirChanged || + geminiDirChanged || + opencodeDirChanged) ) { const syncResult = await syncCurrentProvidersLiveSafe(); if (!syncResult.ok) { @@ -409,6 +443,7 @@ export function useSettings(): UseSettingsResult { saveMutation, settings, setRequiresRestart, + syncClaudePluginIfChanged, t, ], ); diff --git a/tests/hooks/useSettings.test.tsx b/tests/hooks/useSettings.test.tsx index d0a9a5b01..09124931f 100644 --- a/tests/hooks/useSettings.test.tsx +++ b/tests/hooks/useSettings.test.tsx @@ -11,6 +11,8 @@ const applyClaudeOnboardingSkipMock = vi.fn(); const clearClaudeOnboardingSkipMock = vi.fn(); const syncCurrentProvidersLiveMock = vi.fn(); const updateTrayMenuMock = vi.fn(); +const getCurrentMock = vi.fn(); +const getAllMock = vi.fn(); const toastErrorMock = vi.fn(); const toastSuccessMock = vi.fn(); @@ -61,6 +63,8 @@ vi.mock("@/lib/api", () => ({ }, providersApi: { updateTrayMenu: (...args: unknown[]) => updateTrayMenuMock(...args), + getCurrent: (...args: unknown[]) => getCurrentMock(...args), + getAll: (...args: unknown[]) => getAllMock(...args), }, })); @@ -121,6 +125,8 @@ describe("useSettings hook", () => { applyClaudeOnboardingSkipMock.mockReset(); clearClaudeOnboardingSkipMock.mockReset(); syncCurrentProvidersLiveMock.mockReset(); + getCurrentMock.mockReset(); + getAllMock.mockReset(); toastErrorMock.mockReset(); toastSuccessMock.mockReset(); window.localStorage.clear(); @@ -154,6 +160,9 @@ describe("useSettings hook", () => { applyClaudePluginConfigMock.mockResolvedValue(true); applyClaudeOnboardingSkipMock.mockResolvedValue(true); clearClaudeOnboardingSkipMock.mockResolvedValue(true); + syncCurrentProvidersLiveMock.mockResolvedValue({ ok: true }); + getCurrentMock.mockResolvedValue(null); + getAllMock.mockResolvedValue({}); }); it("auto-saves and applies Claude onboarding skip when toggled on", async () => { @@ -262,7 +271,7 @@ describe("useSettings hook", () => { expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(true); expect(window.localStorage.getItem("language")).toBe("en"); expect(toastErrorMock).not.toHaveBeenCalled(); - // 目录有变化,应触发一次同步当前供应商到 live + // 插件同步已包含 syncCurrentProvidersLiveSafe,目录变更不再重复调用 expect(syncCurrentProvidersLiveMock).toHaveBeenCalledTimes(1); });