Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 86 additions & 32 deletions src/hooks/useSettings.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -63,6 +64,7 @@ export function useSettings(): UseSettingsResult {
const { t } = useTranslation();
const { data } = useSettingsQuery();
const saveMutation = useSaveSettingsMutation();
const queryClient = useQueryClient();

// 1️⃣ 表单状态管理
const {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -202,6 +262,11 @@ export function useSettings(): UseSettingsResult {
}
}

await syncClaudePluginIfChanged(
payload.enableClaudePluginIntegration,
prevPluginEnabled,
);

// 持久化语言偏好
try {
if (typeof window !== "undefined" && updates.language) {
Expand Down Expand Up @@ -233,7 +298,7 @@ export function useSettings(): UseSettingsResult {
throw error;
}
},
[data, saveMutation, settings, t],
[data, queryClient, saveMutation, settings, syncClaudePluginIfChanged, t],
);

// 完整保存设置(用于 Advanced 标签页的手动保存)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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") {
Expand All @@ -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) {
Expand Down Expand Up @@ -419,9 +471,11 @@ export function useSettings(): UseSettingsResult {
appConfigDir,
data,
initialAppConfigDir,
queryClient,
saveMutation,
settings,
setRequiresRestart,
syncClaudePluginIfChanged,
t,
],
);
Expand Down
66 changes: 65 additions & 1 deletion tests/hooks/useSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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[]) =>
Expand All @@ -61,6 +76,8 @@ vi.mock("@/lib/api", () => ({
},
providersApi: {
updateTrayMenu: (...args: unknown[]) => updateTrayMenuMock(...args),
getCurrent: (...args: unknown[]) => getCurrentMock(...args),
getAll: (...args: unknown[]) => getAllMock(...args),
},
}));

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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,
Expand Down