refactor(hooks): introduce unified post-change sync utility
- Add postChangeSync.ts utility with Result pattern for graceful error handling - Replace try-catch with syncCurrentProvidersLiveSafe in useImportExport - Add directory-change-triggered sync in useSettings to maintain SSOT - Introduce partial-success status to distinguish import success from sync failures - Add test coverage for sync behavior in different scenarios This refactoring ensures config.json changes are reliably synced to live files while providing better user feedback for edge cases.
This commit is contained in:
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { settingsApi } from "@/lib/api";
|
import { settingsApi } from "@/lib/api";
|
||||||
|
import { syncCurrentProvidersLiveSafe } from "@/utils/postChangeSync";
|
||||||
|
|
||||||
export type ImportStatus =
|
export type ImportStatus =
|
||||||
| "idle"
|
| "idle"
|
||||||
@@ -105,8 +106,8 @@ export function useImportExport(
|
|||||||
|
|
||||||
setBackupId(result.backupId ?? null);
|
setBackupId(result.backupId ?? null);
|
||||||
|
|
||||||
try {
|
const syncResult = await syncCurrentProvidersLiveSafe();
|
||||||
await settingsApi.syncCurrentProvidersLive();
|
if (syncResult.ok) {
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
toast.success(
|
toast.success(
|
||||||
t("settings.importSuccess", {
|
t("settings.importSuccess", {
|
||||||
@@ -117,8 +118,11 @@ export function useImportExport(
|
|||||||
successTimerRef.current = window.setTimeout(() => {
|
successTimerRef.current = window.setTimeout(() => {
|
||||||
void onImportSuccess?.();
|
void onImportSuccess?.();
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} catch (error) {
|
} else {
|
||||||
console.error("[useImportExport] Failed to sync live config", error);
|
console.error(
|
||||||
|
"[useImportExport] Failed to sync live config",
|
||||||
|
syncResult.error,
|
||||||
|
);
|
||||||
setStatus("partial-success");
|
setStatus("partial-success");
|
||||||
toast.warning(
|
toast.warning(
|
||||||
t("settings.importPartialSuccess", {
|
t("settings.importPartialSuccess", {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { providersApi, settingsApi, type AppId } from "@/lib/api";
|
import { providersApi, settingsApi, type AppId } from "@/lib/api";
|
||||||
|
import { syncCurrentProvidersLiveSafe } from "@/utils/postChangeSync";
|
||||||
import { useSettingsQuery, useSaveSettingsMutation } from "@/lib/query";
|
import { useSettingsQuery, useSaveSettingsMutation } from "@/lib/query";
|
||||||
import type { Settings } from "@/types";
|
import type { Settings } from "@/types";
|
||||||
import { useSettingsForm, type SettingsFormState } from "./useSettingsForm";
|
import { useSettingsForm, type SettingsFormState } from "./useSettingsForm";
|
||||||
@@ -120,6 +121,8 @@ export function useSettings(): UseSettingsResult {
|
|||||||
const sanitizedClaudeDir = sanitizeDir(settings.claudeConfigDir);
|
const sanitizedClaudeDir = sanitizeDir(settings.claudeConfigDir);
|
||||||
const sanitizedCodexDir = sanitizeDir(settings.codexConfigDir);
|
const sanitizedCodexDir = sanitizeDir(settings.codexConfigDir);
|
||||||
const previousAppDir = initialAppConfigDir;
|
const previousAppDir = initialAppConfigDir;
|
||||||
|
const previousClaudeDir = sanitizeDir(data?.claudeConfigDir);
|
||||||
|
const previousCodexDir = sanitizeDir(data?.codexConfigDir);
|
||||||
|
|
||||||
const payload: Settings = {
|
const payload: Settings = {
|
||||||
...settings,
|
...settings,
|
||||||
@@ -167,6 +170,19 @@ export function useSettings(): UseSettingsResult {
|
|||||||
console.warn("[useSettings] Failed to refresh tray menu", error);
|
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);
|
const appDirChanged = sanitizedAppDir !== (previousAppDir ?? undefined);
|
||||||
setRequiresRestart(appDirChanged);
|
setRequiresRestart(appDirChanged);
|
||||||
|
|
||||||
@@ -177,6 +193,7 @@ export function useSettings(): UseSettingsResult {
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
appConfigDir,
|
appConfigDir,
|
||||||
|
data,
|
||||||
initialAppConfigDir,
|
initialAppConfigDir,
|
||||||
saveMutation,
|
saveMutation,
|
||||||
settings,
|
settings,
|
||||||
|
|||||||
19
src/utils/postChangeSync.ts
Normal file
19
src/utils/postChangeSync.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ const mutateAsyncMock = vi.fn();
|
|||||||
const useSettingsQueryMock = vi.fn();
|
const useSettingsQueryMock = vi.fn();
|
||||||
const setAppConfigDirOverrideMock = vi.fn();
|
const setAppConfigDirOverrideMock = vi.fn();
|
||||||
const applyClaudePluginConfigMock = vi.fn();
|
const applyClaudePluginConfigMock = vi.fn();
|
||||||
|
const syncCurrentProvidersLiveMock = vi.fn();
|
||||||
const toastErrorMock = vi.fn();
|
const toastErrorMock = vi.fn();
|
||||||
const toastSuccessMock = vi.fn();
|
const toastSuccessMock = vi.fn();
|
||||||
|
|
||||||
@@ -48,6 +49,8 @@ vi.mock("@/lib/api", () => ({
|
|||||||
setAppConfigDirOverrideMock(...args),
|
setAppConfigDirOverrideMock(...args),
|
||||||
applyClaudePluginConfig: (...args: unknown[]) =>
|
applyClaudePluginConfig: (...args: unknown[]) =>
|
||||||
applyClaudePluginConfigMock(...args),
|
applyClaudePluginConfigMock(...args),
|
||||||
|
syncCurrentProvidersLive: (...args: unknown[]) =>
|
||||||
|
syncCurrentProvidersLiveMock(...args),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -102,6 +105,7 @@ describe("useSettings hook", () => {
|
|||||||
useSettingsQueryMock.mockReset();
|
useSettingsQueryMock.mockReset();
|
||||||
setAppConfigDirOverrideMock.mockReset();
|
setAppConfigDirOverrideMock.mockReset();
|
||||||
applyClaudePluginConfigMock.mockReset();
|
applyClaudePluginConfigMock.mockReset();
|
||||||
|
syncCurrentProvidersLiveMock.mockReset();
|
||||||
toastErrorMock.mockReset();
|
toastErrorMock.mockReset();
|
||||||
toastSuccessMock.mockReset();
|
toastSuccessMock.mockReset();
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
@@ -181,6 +185,8 @@ describe("useSettings hook", () => {
|
|||||||
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(true);
|
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(true);
|
||||||
expect(window.localStorage.getItem("language")).toBe("en");
|
expect(window.localStorage.getItem("language")).toBe("en");
|
||||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||||
|
// 目录有变化,应触发一次同步当前供应商到 live
|
||||||
|
expect(syncCurrentProvidersLiveMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saves settings without restart when directory unchanged", async () => {
|
it("saves settings without restart when directory unchanged", async () => {
|
||||||
@@ -209,6 +215,8 @@ describe("useSettings hook", () => {
|
|||||||
expect(setAppConfigDirOverrideMock).toHaveBeenCalledWith(null);
|
expect(setAppConfigDirOverrideMock).toHaveBeenCalledWith(null);
|
||||||
expect(applyClaudePluginConfigMock).toHaveBeenCalledWith({ official: true });
|
expect(applyClaudePluginConfigMock).toHaveBeenCalledWith({ official: true });
|
||||||
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false);
|
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false);
|
||||||
|
// 目录未变化,不应触发同步
|
||||||
|
expect(syncCurrentProvidersLiveMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows toast when Claude plugin sync fails but continues flow", async () => {
|
it("shows toast when Claude plugin sync fails but continues flow", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user