mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-17 22:37:50 +08:00
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:
+86
-32
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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 () => {
|
||||
// 模拟快速连切后的 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,
|
||||
|
||||
Reference in New Issue
Block a user