feat(settings): add autoSaveSettings for lightweight auto-save
Add optimized auto-save function for General tab settings. - Add autoSaveSettings method for non-destructive auto-save - Only trigger system APIs when values actually change - Avoid unnecessary auto-launch and plugin config updates - Update tests to cover new functionality
This commit is contained in:
@@ -37,6 +37,9 @@ export interface UseSettingsResult {
|
|||||||
overrides?: Partial<SettingsFormState>,
|
overrides?: Partial<SettingsFormState>,
|
||||||
options?: { silent?: boolean },
|
options?: { silent?: boolean },
|
||||||
) => Promise<SaveResult | null>;
|
) => Promise<SaveResult | null>;
|
||||||
|
autoSaveSettings: (
|
||||||
|
updates: Partial<SettingsFormState>,
|
||||||
|
) => Promise<SaveResult | null>;
|
||||||
resetSettings: () => void;
|
resetSettings: () => void;
|
||||||
acknowledgeRestart: () => void;
|
acknowledgeRestart: () => void;
|
||||||
}
|
}
|
||||||
@@ -117,7 +120,82 @@ export function useSettings(): UseSettingsResult {
|
|||||||
setRequiresRestart,
|
setRequiresRestart,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 保存设置
|
// 即时保存设置(用于 General 标签页的实时更新)
|
||||||
|
// 保存基础配置 + 独立的系统 API 调用(开机自启)
|
||||||
|
const autoSaveSettings = useCallback(
|
||||||
|
async (updates: Partial<SettingsFormState>): Promise<SaveResult | null> => {
|
||||||
|
const mergedSettings = settings ? { ...settings, ...updates } : null;
|
||||||
|
if (!mergedSettings) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
|
||||||
|
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
|
||||||
|
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
|
||||||
|
|
||||||
|
const payload: Settings = {
|
||||||
|
...mergedSettings,
|
||||||
|
claudeConfigDir: sanitizedClaudeDir,
|
||||||
|
codexConfigDir: sanitizedCodexDir,
|
||||||
|
geminiConfigDir: sanitizedGeminiDir,
|
||||||
|
language: mergedSettings.language,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存到配置文件
|
||||||
|
await saveMutation.mutateAsync(payload);
|
||||||
|
|
||||||
|
// 如果开机自启状态改变,调用系统 API
|
||||||
|
if (
|
||||||
|
payload.launchOnStartup !== undefined &&
|
||||||
|
payload.launchOnStartup !== data?.launchOnStartup
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await settingsApi.setAutoLaunch(payload.launchOnStartup);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update auto-launch:", error);
|
||||||
|
toast.error(
|
||||||
|
t("settings.autoLaunchFailed", {
|
||||||
|
defaultValue: "设置开机自启失败",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 持久化语言偏好
|
||||||
|
try {
|
||||||
|
if (typeof window !== "undefined" && updates.language) {
|
||||||
|
window.localStorage.setItem("language", updates.language);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"[useSettings] Failed to persist language preference",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新托盘菜单
|
||||||
|
try {
|
||||||
|
await providersApi.updateTrayMenu();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[useSettings] Failed to refresh tray menu", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requiresRestart: false };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[useSettings] Failed to auto-save settings", error);
|
||||||
|
toast.error(
|
||||||
|
t("notifications.settingsSaveFailed", {
|
||||||
|
defaultValue: "保存设置失败: {{error}}",
|
||||||
|
error: (error as Error)?.message ?? String(error),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[data, saveMutation, settings, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 完整保存设置(用于 Advanced 标签页的手动保存)
|
||||||
|
// 包含所有系统 API 调用和完整的验证流程
|
||||||
const saveSettings = useCallback(
|
const saveSettings = useCallback(
|
||||||
async (
|
async (
|
||||||
overrides?: Partial<SettingsFormState>,
|
overrides?: Partial<SettingsFormState>,
|
||||||
@@ -147,8 +225,11 @@ export function useSettings(): UseSettingsResult {
|
|||||||
|
|
||||||
await settingsApi.setAppConfigDirOverride(sanitizedAppDir ?? null);
|
await settingsApi.setAppConfigDirOverride(sanitizedAppDir ?? null);
|
||||||
|
|
||||||
// 如果开机自启状态改变,调用系统 API
|
// 只在开机自启状态真正改变时调用系统 API
|
||||||
if (payload.launchOnStartup !== undefined) {
|
if (
|
||||||
|
payload.launchOnStartup !== undefined &&
|
||||||
|
payload.launchOnStartup !== data?.launchOnStartup
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
await settingsApi.setAutoLaunch(payload.launchOnStartup);
|
await settingsApi.setAutoLaunch(payload.launchOnStartup);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -161,22 +242,29 @@ export function useSettings(): UseSettingsResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// 只在 Claude 插件集成状态真正改变时调用系统 API
|
||||||
if (payload.enableClaudePluginIntegration) {
|
if (
|
||||||
await settingsApi.applyClaudePluginConfig({ official: false });
|
payload.enableClaudePluginIntegration !== undefined &&
|
||||||
} else {
|
payload.enableClaudePluginIntegration !==
|
||||||
await settingsApi.applyClaudePluginConfig({ official: true });
|
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 插件失败",
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
"[useSettings] Failed to sync Claude plugin config",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
toast.error(
|
|
||||||
t("notifications.syncClaudePluginFailed", {
|
|
||||||
defaultValue: "同步 Claude 插件失败",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -268,6 +356,7 @@ export function useSettings(): UseSettingsResult {
|
|||||||
resetDirectory,
|
resetDirectory,
|
||||||
resetAppConfigDir,
|
resetAppConfigDir,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
|
autoSaveSettings,
|
||||||
resetSettings,
|
resetSettings,
|
||||||
acknowledgeRestart,
|
acknowledgeRestart,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ describe("useDirectorySettings", () => {
|
|||||||
appConfig: "/override/app",
|
appConfig: "/override/app",
|
||||||
claude: "/remote/claude",
|
claude: "/remote/claude",
|
||||||
codex: "/remote/codex",
|
codex: "/remote/codex",
|
||||||
|
gemini: "/remote/codex", // Gemini 使用 codex 作为默认
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ describe("useSettings hook", () => {
|
|||||||
it("saves settings and flags restart when app config directory changes", async () => {
|
it("saves settings and flags restart when app config directory changes", async () => {
|
||||||
serverSettings = {
|
serverSettings = {
|
||||||
...serverSettings,
|
...serverSettings,
|
||||||
enableClaudePluginIntegration: true,
|
enableClaudePluginIntegration: false,
|
||||||
claudeConfigDir: "/server/claude",
|
claudeConfigDir: "/server/claude",
|
||||||
codexConfigDir: undefined,
|
codexConfigDir: undefined,
|
||||||
language: "en",
|
language: "en",
|
||||||
@@ -159,7 +159,7 @@ describe("useSettings hook", () => {
|
|||||||
claudeConfigDir: " /custom/claude ",
|
claudeConfigDir: " /custom/claude ",
|
||||||
codexConfigDir: " ",
|
codexConfigDir: " ",
|
||||||
language: "en",
|
language: "en",
|
||||||
enableClaudePluginIntegration: true,
|
enableClaudePluginIntegration: true, // 状态从 false 变为 true
|
||||||
},
|
},
|
||||||
initialLanguage: "en",
|
initialLanguage: "en",
|
||||||
});
|
});
|
||||||
@@ -183,6 +183,7 @@ describe("useSettings hook", () => {
|
|||||||
expect(payload.codexConfigDir).toBeUndefined();
|
expect(payload.codexConfigDir).toBeUndefined();
|
||||||
expect(payload.language).toBe("en");
|
expect(payload.language).toBe("en");
|
||||||
expect(setAppConfigDirOverrideMock).toHaveBeenCalledWith("/override/app");
|
expect(setAppConfigDirOverrideMock).toHaveBeenCalledWith("/override/app");
|
||||||
|
// 状态改变,应该调用 API
|
||||||
expect(applyClaudePluginConfigMock).toHaveBeenCalledWith({
|
expect(applyClaudePluginConfigMock).toHaveBeenCalledWith({
|
||||||
official: false,
|
official: false,
|
||||||
});
|
});
|
||||||
@@ -194,10 +195,22 @@ describe("useSettings hook", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("saves settings without restart when directory unchanged", async () => {
|
it("saves settings without restart when directory unchanged", async () => {
|
||||||
|
// 确保服务器和本地状态一致,不触发 API 调用
|
||||||
|
serverSettings = {
|
||||||
|
...serverSettings,
|
||||||
|
enableClaudePluginIntegration: false,
|
||||||
|
launchOnStartup: false,
|
||||||
|
};
|
||||||
|
useSettingsQueryMock.mockReturnValue({
|
||||||
|
data: serverSettings,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
settingsFormMock = createSettingsFormMock({
|
settingsFormMock = createSettingsFormMock({
|
||||||
settings: {
|
settings: {
|
||||||
...serverSettings,
|
...serverSettings,
|
||||||
enableClaudePluginIntegration: false,
|
enableClaudePluginIntegration: false, // 状态未变
|
||||||
|
launchOnStartup: false, // 状态未变
|
||||||
language: "zh",
|
language: "zh",
|
||||||
},
|
},
|
||||||
initialLanguage: "zh",
|
initialLanguage: "zh",
|
||||||
@@ -217,19 +230,28 @@ describe("useSettings hook", () => {
|
|||||||
|
|
||||||
expect(saveResult).toEqual({ requiresRestart: false });
|
expect(saveResult).toEqual({ requiresRestart: false });
|
||||||
expect(setAppConfigDirOverrideMock).toHaveBeenCalledWith(null);
|
expect(setAppConfigDirOverrideMock).toHaveBeenCalledWith(null);
|
||||||
expect(applyClaudePluginConfigMock).toHaveBeenCalledWith({
|
// 状态未改变,不应调用 API
|
||||||
official: true,
|
expect(applyClaudePluginConfigMock).not.toHaveBeenCalled();
|
||||||
});
|
|
||||||
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false);
|
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false);
|
||||||
// 目录未变化,不应触发同步
|
// 目录未变化,不应触发同步
|
||||||
expect(syncCurrentProvidersLiveMock).not.toHaveBeenCalled();
|
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 () => {
|
||||||
|
// 设置服务器状态为 false,本地状态为 true,触发状态变化
|
||||||
|
serverSettings = {
|
||||||
|
...serverSettings,
|
||||||
|
enableClaudePluginIntegration: false,
|
||||||
|
};
|
||||||
|
useSettingsQueryMock.mockReturnValue({
|
||||||
|
data: serverSettings,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
settingsFormMock = createSettingsFormMock({
|
settingsFormMock = createSettingsFormMock({
|
||||||
settings: {
|
settings: {
|
||||||
...serverSettings,
|
...serverSettings,
|
||||||
enableClaudePluginIntegration: true,
|
enableClaudePluginIntegration: true, // 状态改变
|
||||||
language: "zh",
|
language: "zh",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -286,6 +308,7 @@ describe("useSettings hook", () => {
|
|||||||
expect(directorySettingsMock.resetAllDirectories).toHaveBeenCalledWith(
|
expect(directorySettingsMock.resetAllDirectories).toHaveBeenCalledWith(
|
||||||
"/server/claude",
|
"/server/claude",
|
||||||
undefined,
|
undefined,
|
||||||
|
undefined, // geminiConfigDir
|
||||||
);
|
);
|
||||||
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false);
|
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user