diff --git a/src/hooks/useImportExport.ts b/src/hooks/useImportExport.ts index 9ee568d..eb56943 100644 --- a/src/hooks/useImportExport.ts +++ b/src/hooks/useImportExport.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { settingsApi } from "@/lib/api"; +import { syncCurrentProvidersLiveSafe } from "@/utils/postChangeSync"; export type ImportStatus = | "idle" @@ -105,8 +106,8 @@ export function useImportExport( setBackupId(result.backupId ?? null); - try { - await settingsApi.syncCurrentProvidersLive(); + const syncResult = await syncCurrentProvidersLiveSafe(); + if (syncResult.ok) { setStatus("success"); toast.success( t("settings.importSuccess", { @@ -117,8 +118,11 @@ export function useImportExport( successTimerRef.current = window.setTimeout(() => { void onImportSuccess?.(); }, 1500); - } catch (error) { - console.error("[useImportExport] Failed to sync live config", error); + } else { + console.error( + "[useImportExport] Failed to sync live config", + syncResult.error, + ); setStatus("partial-success"); toast.warning( t("settings.importPartialSuccess", { diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index c02a442..0f29e0a 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { providersApi, settingsApi, type AppId } from "@/lib/api"; +import { syncCurrentProvidersLiveSafe } from "@/utils/postChangeSync"; import { useSettingsQuery, useSaveSettingsMutation } from "@/lib/query"; import type { Settings } from "@/types"; import { useSettingsForm, type SettingsFormState } from "./useSettingsForm"; @@ -120,6 +121,8 @@ export function useSettings(): UseSettingsResult { const sanitizedClaudeDir = sanitizeDir(settings.claudeConfigDir); const sanitizedCodexDir = sanitizeDir(settings.codexConfigDir); const previousAppDir = initialAppConfigDir; + const previousClaudeDir = sanitizeDir(data?.claudeConfigDir); + const previousCodexDir = sanitizeDir(data?.codexConfigDir); const payload: Settings = { ...settings, @@ -167,6 +170,19 @@ export function useSettings(): UseSettingsResult { console.warn("[useSettings] Failed to refresh tray menu", error); } + // 如果 Claude/Codex 的目录覆盖发生变化,则立即将“当前使用的供应商”写回对应应用的 live 配置 + const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir; + const codexDirChanged = sanitizedCodexDir !== previousCodexDir; + if (claudeDirChanged || codexDirChanged) { + const syncResult = await syncCurrentProvidersLiveSafe(); + if (!syncResult.ok) { + console.warn( + "[useSettings] Failed to sync current providers after directory change", + syncResult.error, + ); + } + } + const appDirChanged = sanitizedAppDir !== (previousAppDir ?? undefined); setRequiresRestart(appDirChanged); @@ -177,6 +193,7 @@ export function useSettings(): UseSettingsResult { } }, [ appConfigDir, + data, initialAppConfigDir, saveMutation, settings, diff --git a/src/utils/postChangeSync.ts b/src/utils/postChangeSync.ts new file mode 100644 index 0000000..ed8c1d9 --- /dev/null +++ b/src/utils/postChangeSync.ts @@ -0,0 +1,19 @@ +import { settingsApi } from "@/lib/api"; + +/** + * 统一的“后置同步”工具:将当前使用的供应商写回对应应用的 live 配置。 + * 不抛出异常,由调用方根据返回值决定提示策略。 + */ +export async function syncCurrentProvidersLiveSafe(): Promise<{ + ok: boolean; + error?: Error; +}> { + try { + await settingsApi.syncCurrentProvidersLive(); + return { ok: true }; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err ?? "")); + return { ok: false, error }; + } +} + diff --git a/tests/hooks/useSettings.test.tsx b/tests/hooks/useSettings.test.tsx index da91602..053a019 100644 --- a/tests/hooks/useSettings.test.tsx +++ b/tests/hooks/useSettings.test.tsx @@ -7,6 +7,7 @@ const mutateAsyncMock = vi.fn(); const useSettingsQueryMock = vi.fn(); const setAppConfigDirOverrideMock = vi.fn(); const applyClaudePluginConfigMock = vi.fn(); +const syncCurrentProvidersLiveMock = vi.fn(); const toastErrorMock = vi.fn(); const toastSuccessMock = vi.fn(); @@ -48,6 +49,8 @@ vi.mock("@/lib/api", () => ({ setAppConfigDirOverrideMock(...args), applyClaudePluginConfig: (...args: unknown[]) => applyClaudePluginConfigMock(...args), + syncCurrentProvidersLive: (...args: unknown[]) => + syncCurrentProvidersLiveMock(...args), }, })); @@ -102,6 +105,7 @@ describe("useSettings hook", () => { useSettingsQueryMock.mockReset(); setAppConfigDirOverrideMock.mockReset(); applyClaudePluginConfigMock.mockReset(); + syncCurrentProvidersLiveMock.mockReset(); toastErrorMock.mockReset(); toastSuccessMock.mockReset(); window.localStorage.clear(); @@ -181,6 +185,8 @@ describe("useSettings hook", () => { expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(true); expect(window.localStorage.getItem("language")).toBe("en"); expect(toastErrorMock).not.toHaveBeenCalled(); + // 目录有变化,应触发一次同步当前供应商到 live + expect(syncCurrentProvidersLiveMock).toHaveBeenCalledTimes(1); }); it("saves settings without restart when directory unchanged", async () => { @@ -209,6 +215,8 @@ describe("useSettings hook", () => { expect(setAppConfigDirOverrideMock).toHaveBeenCalledWith(null); expect(applyClaudePluginConfigMock).toHaveBeenCalledWith({ official: true }); expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false); + // 目录未变化,不应触发同步 + expect(syncCurrentProvidersLiveMock).not.toHaveBeenCalled(); }); it("shows toast when Claude plugin sync fails but continues flow", async () => {