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,
],
);