fix(claude-plugin): sync current provider config to settings.json (#1905)

* 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

* style: reformat after rebase onto main

Prettier flagged a line-break introduced by the openclaw directory
change (from main) after rebase.

* fix(claude-plugin): read prev enabled state from live cache to avoid stale closure

syncClaudePluginIfChanged compared enabled against data?.enableClaudePluginIntegration
captured in a useCallback closure. After invalidateQueries + refetch, the React
Query cache is up to date, but the consuming hook's closure does not see the new
value until React re-renders. Quick on->off toggles could therefore skip
applyClaudePluginConfig, leaving ~/.claude/config.json in the previously enabled
state even though settings.json was persisted as disabled.

Read the previous value synchronously from queryClient.getQueryData(["settings"])
before saveMutation.mutateAsync(), then pass it to the helper as prevEnabled.
getQueryData bypasses the closure and reflects the live cache at call time.

Test covers the race: closure data stays at false while the cache reports true;
the helper must still call applyClaudePluginConfig({ official: true }).

---------

Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
chengww
2026-04-21 11:56:13 +08:00
committed by GitHub
parent cc77a040e2
commit c5b15dd25e
2 changed files with 151 additions and 33 deletions
+86 -32
View File
@@ -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<boolean> => {
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>([
"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>([
"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,
],
);
+65 -1
View File
@@ -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<typeof import("@tanstack/react-query")>(
"@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 () => {
// 模拟快速连切后的 raceuseSettingsQueryMock 的 data 滞后停留在 falseclosure 未更新),
// 但 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();
// 缓存里的"真实上次值"是 trueenabled),与 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,