diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 121f25c72e..c7c77af969 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -1,6 +1,7 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; +import { useQueryClient } from "@tanstack/react-query"; import { providersApi, settingsApi, type AppId } from "@/lib/api"; import { syncCurrentProvidersLiveSafe } from "@/utils/postChangeSync"; import { useSettingsQuery, useSaveSettingsMutation } from "@/lib/query"; @@ -63,6 +64,7 @@ export function useSettings(): UseSettingsResult { const { t } = useTranslation(); const { data } = useSettingsQuery(); const saveMutation = useSaveSettingsMutation(); + const queryClient = useQueryClient(); // 1️⃣ 表单状态管理 const { @@ -122,6 +124,58 @@ export function useSettings(): UseSettingsResult { setRequiresRestart, ]); + // 同步 Claude 插件集成配置到 ~/.claude/settings.json + // 返回 true 表示已执行过 syncCurrentProvidersLiveSafe,调用方可跳过重复同步 + // prevEnabled 必须由调用方在 saveMutation 之前从实时缓存(queryClient.getQueryData)捕获, + // 避免 useCallback closure 中 data 因未 re-render 而滞后导致的快速连切 race。 + const syncClaudePluginIfChanged = useCallback( + async ( + enabled: boolean | undefined, + prevEnabled: boolean | undefined, + ): Promise => { + if (enabled === undefined || enabled === prevEnabled) 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; + } + }, + [t], + ); + // 即时保存设置(用于 General 标签页的实时更新) // 保存基础配置 + 独立的系统 API 调用(开机自启) const autoSaveSettings = useCallback( @@ -152,6 +206,12 @@ export function useSettings(): UseSettingsResult { language: mergedSettings.language, }; + // 在 mutate 之前从实时缓存捕获上一次持久化的插件集成状态, + // 避免 closure 里的 data 因 React 尚未 re-render 而滞后 + const prevPluginEnabled = queryClient.getQueryData([ + "settings", + ])?.enableClaudePluginIntegration; + // 保存到配置文件 await saveMutation.mutateAsync(payload); @@ -202,6 +262,11 @@ export function useSettings(): UseSettingsResult { } } + await syncClaudePluginIfChanged( + payload.enableClaudePluginIntegration, + prevPluginEnabled, + ); + // 持久化语言偏好 try { if (typeof window !== "undefined" && updates.language) { @@ -233,7 +298,7 @@ export function useSettings(): UseSettingsResult { throw error; } }, - [data, saveMutation, settings, t], + [data, queryClient, saveMutation, settings, syncClaudePluginIfChanged, t], ); // 完整保存设置(用于 Advanced 标签页的手动保存) @@ -275,6 +340,12 @@ export function useSettings(): UseSettingsResult { language: mergedSettings.language, }; + // 在 mutate 之前从实时缓存捕获上一次持久化的插件集成状态, + // 避免 closure 里的 data 因 React 尚未 re-render 而滞后 + const prevPluginEnabled = queryClient.getQueryData([ + "settings", + ])?.enableClaudePluginIntegration; + await saveMutation.mutateAsync(payload); await settingsApi.setAppConfigDirOverride(sanitizedAppDir ?? null); @@ -323,30 +394,10 @@ 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, + prevPluginEnabled, + ); try { if (typeof window !== "undefined") { @@ -369,18 +420,19 @@ export function useSettings(): UseSettingsResult { } // 如果 Claude/Codex/Gemini/OpenCode/OpenClaw 的目录覆盖发生变化,则立即将"当前使用的供应商"写回对应应用的 live 配置 + // 如果插件同步已经执行过 syncCurrentProvidersLiveSafe,则跳过避免重复 const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir; const codexDirChanged = sanitizedCodexDir !== previousCodexDir; const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir; const opencodeDirChanged = sanitizedOpencodeDir !== previousOpencodeDir; - const openclawDirChanged = - sanitizedOpenclawDir !== previousOpenclawDir; + const openclawDirChanged = sanitizedOpenclawDir !== previousOpenclawDir; if ( - claudeDirChanged || - codexDirChanged || - geminiDirChanged || - opencodeDirChanged || - openclawDirChanged + !pluginSynced && + (claudeDirChanged || + codexDirChanged || + geminiDirChanged || + opencodeDirChanged || + openclawDirChanged) ) { const syncResult = await syncCurrentProvidersLiveSafe(); if (!syncResult.ok) { @@ -419,9 +471,11 @@ export function useSettings(): UseSettingsResult { appConfigDir, data, initialAppConfigDir, + queryClient, saveMutation, settings, setRequiresRestart, + syncClaudePluginIfChanged, t, ], ); diff --git a/tests/hooks/useSettings.test.tsx b/tests/hooks/useSettings.test.tsx index 98f11176aa..a4c011e9de 100644 --- a/tests/hooks/useSettings.test.tsx +++ b/tests/hooks/useSettings.test.tsx @@ -11,6 +11,9 @@ 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 getQueryDataMock = vi.fn(); const toastErrorMock = vi.fn(); const toastSuccessMock = vi.fn(); @@ -46,6 +49,18 @@ vi.mock("@/lib/query", () => ({ }), })); +vi.mock("@tanstack/react-query", async () => { + const actual = await vi.importActual( + "@tanstack/react-query", + ); + return { + ...actual, + useQueryClient: () => ({ + getQueryData: (...args: unknown[]) => getQueryDataMock(...args), + }), + }; +}); + vi.mock("@/lib/api", () => ({ settingsApi: { setAppConfigDirOverride: (...args: unknown[]) => @@ -61,6 +76,8 @@ vi.mock("@/lib/api", () => ({ }, providersApi: { updateTrayMenu: (...args: unknown[]) => updateTrayMenuMock(...args), + getCurrent: (...args: unknown[]) => getCurrentMock(...args), + getAll: (...args: unknown[]) => getAllMock(...args), }, })); @@ -127,6 +144,9 @@ describe("useSettings hook", () => { applyClaudeOnboardingSkipMock.mockReset(); clearClaudeOnboardingSkipMock.mockReset(); syncCurrentProvidersLiveMock.mockReset(); + getCurrentMock.mockReset(); + getAllMock.mockReset(); + getQueryDataMock.mockReset(); toastErrorMock.mockReset(); toastSuccessMock.mockReset(); window.localStorage.clear(); @@ -163,6 +183,11 @@ describe("useSettings hook", () => { applyClaudePluginConfigMock.mockResolvedValue(true); applyClaudeOnboardingSkipMock.mockResolvedValue(true); clearClaudeOnboardingSkipMock.mockResolvedValue(true); + syncCurrentProvidersLiveMock.mockResolvedValue({ ok: true }); + getCurrentMock.mockResolvedValue(null); + getAllMock.mockResolvedValue({}); + // 默认将 queryClient 缓存对齐到 serverSettings,既有断言的 "prev === data" 语义保持不变 + getQueryDataMock.mockImplementation(() => serverSettings); }); it("auto-saves and applies Claude onboarding skip when toggled on", async () => { @@ -276,7 +301,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); }); @@ -360,6 +385,45 @@ describe("useSettings hook", () => { expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(true); }); + it("detects plugin toggle via live cache even when closure data is stale", async () => { + // 模拟快速连切后的 race:useSettingsQueryMock 的 data 滞后停留在 false(closure 未更新), + // 但 queryClient 缓存(getQueryData)实时值已为 true(上次持久化到 enabled), + // form 里用户想切回 false。旧实现会因 data === form 而跳过副作用;新实现应读 prev=true 并执行。 + serverSettings = { + ...serverSettings, + enableClaudePluginIntegration: false, + }; + useSettingsQueryMock.mockReturnValue({ + data: serverSettings, + isLoading: false, + }); + + settingsFormMock = createSettingsFormMock({ + settings: { + ...serverSettings, + enableClaudePluginIntegration: false, + language: "zh", + }, + }); + directorySettingsMock = createDirectorySettingsMock(); + + // 缓存里的"真实上次值"是 true(enabled),与 closure data(false) 有时序差 + getQueryDataMock.mockImplementation(() => ({ + ...serverSettings, + enableClaudePluginIntegration: true, + })); + + const { result } = renderHook(() => useSettings()); + + await act(async () => { + await result.current.saveSettings(undefined, { silent: true }); + }); + + // 修复生效:读的是缓存实时值 true,payload=false,差异触发 clear_claude_config + expect(applyClaudePluginConfigMock).toHaveBeenCalledWith({ official: true }); + expect(syncCurrentProvidersLiveMock).toHaveBeenCalled(); + }); + it("resets form, language and directories using server data", () => { serverSettings = { ...serverSettings,