From 239c6fb2d7cb41a7e2298101f555d0823b389fcc Mon Sep 17 00:00:00 2001
From: Jason
Date: Wed, 11 Mar 2026 14:40:14 +0800
Subject: [PATCH] fix: prevent common config modal infinite reopen loop and add
draft editing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The auto-open useEffect in CodexConfigEditor and GeminiConfigEditor
created an inescapable loop: commonConfigError triggered modal open,
closing the modal didn't clear the error, so the effect immediately
reopened it — locking the entire UI.
- Remove auto-open useEffect from both Codex and Gemini config editors
- Convert common config modals to draft editing (edit locally, validate
before save) instead of persisting on every keystroke
- Add TOML/JSON pre-validation via parseCommonConfigSnippet before any
merge operation to prevent invalid content from being persisted
- Expose clearCommonConfigError so editors can clear stale errors on
modal close
---
.../forms/CodexCommonConfigModal.tsx | 32 +-
.../providers/forms/CodexConfigEditor.tsx | 21 +-
.../forms/GeminiCommonConfigModal.tsx | 32 +-
.../providers/forms/GeminiConfigEditor.tsx | 20 +-
.../providers/forms/ProviderForm.tsx | 4 +
.../forms/hooks/useCodexCommonConfig.ts | 303 ++++++++++++------
.../forms/hooks/useGeminiCommonConfig.ts | 188 ++++++-----
.../CommonConfigModalBehavior.test.tsx | 121 +++++++
tests/hooks/useCommonConfigSave.test.tsx | 79 +++++
9 files changed, 596 insertions(+), 204 deletions(-)
create mode 100644 tests/components/CommonConfigModalBehavior.test.tsx
create mode 100644 tests/hooks/useCommonConfigSave.test.tsx
diff --git a/src/components/providers/forms/CodexCommonConfigModal.tsx b/src/components/providers/forms/CodexCommonConfigModal.tsx
index e8ddb866..67204ff5 100644
--- a/src/components/providers/forms/CodexCommonConfigModal.tsx
+++ b/src/components/providers/forms/CodexCommonConfigModal.tsx
@@ -9,7 +9,7 @@ interface CodexCommonConfigModalProps {
isOpen: boolean;
onClose: () => void;
value: string;
- onChange: (value: string) => void;
+ onSave: (value: string) => boolean;
error?: string;
onExtract?: () => void;
isExtracting?: boolean;
@@ -23,13 +23,14 @@ export const CodexCommonConfigModal: React.FC = ({
isOpen,
onClose,
value,
- onChange,
+ onSave,
error,
onExtract,
isExtracting,
}) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
+ const [draftValue, setDraftValue] = useState(value);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
@@ -46,11 +47,28 @@ export const CodexCommonConfigModal: React.FC = ({
return () => observer.disconnect();
}, []);
+ useEffect(() => {
+ if (isOpen) {
+ setDraftValue(value);
+ }
+ }, [isOpen, value]);
+
+ const handleClose = () => {
+ setDraftValue(value);
+ onClose();
+ };
+
+ const handleSave = () => {
+ if (onSave(draftValue)) {
+ onClose();
+ }
+ };
+
return (
{onExtract && (
@@ -71,10 +89,10 @@ export const CodexCommonConfigModal: React.FC = ({
})}
)}
-
void;
+ onCommonConfigSnippetChange: (value: string) => boolean;
+
+ onCommonConfigErrorClear: () => void;
commonConfigError: string;
@@ -42,6 +44,7 @@ const CodexConfigEditor: React.FC = ({
onCommonConfigToggle,
commonConfigSnippet,
onCommonConfigSnippetChange,
+ onCommonConfigErrorClear,
commonConfigError,
authError,
configError,
@@ -50,12 +53,10 @@ const CodexConfigEditor: React.FC = ({
}) => {
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
- // Auto-open common config modal if there's an error
- useEffect(() => {
- if (commonConfigError && !isCommonConfigModalOpen) {
- setIsCommonConfigModalOpen(true);
- }
- }, [commonConfigError, isCommonConfigModalOpen]);
+ const handleCloseCommonConfigModal = () => {
+ onCommonConfigErrorClear();
+ setIsCommonConfigModalOpen(false);
+ };
return (
@@ -81,9 +82,9 @@ const CodexConfigEditor: React.FC
= ({
{/* Common Config Modal */}
setIsCommonConfigModalOpen(false)}
+ onClose={handleCloseCommonConfigModal}
value={commonConfigSnippet}
- onChange={onCommonConfigSnippetChange}
+ onSave={onCommonConfigSnippetChange}
error={commonConfigError}
onExtract={onExtract}
isExtracting={isExtracting}
diff --git a/src/components/providers/forms/GeminiCommonConfigModal.tsx b/src/components/providers/forms/GeminiCommonConfigModal.tsx
index c7413217..98e593a1 100644
--- a/src/components/providers/forms/GeminiCommonConfigModal.tsx
+++ b/src/components/providers/forms/GeminiCommonConfigModal.tsx
@@ -9,7 +9,7 @@ interface GeminiCommonConfigModalProps {
isOpen: boolean;
onClose: () => void;
value: string;
- onChange: (value: string) => void;
+ onSave: (value: string) => boolean;
error?: string;
onExtract?: () => void;
isExtracting?: boolean;
@@ -21,9 +21,10 @@ interface GeminiCommonConfigModalProps {
*/
export const GeminiCommonConfigModal: React.FC<
GeminiCommonConfigModalProps
-> = ({ isOpen, onClose, value, onChange, error, onExtract, isExtracting }) => {
+> = ({ isOpen, onClose, value, onSave, error, onExtract, isExtracting }) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
+ const [draftValue, setDraftValue] = useState(value);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
@@ -40,13 +41,30 @@ export const GeminiCommonConfigModal: React.FC<
return () => observer.disconnect();
}, []);
+ useEffect(() => {
+ if (isOpen) {
+ setDraftValue(value);
+ }
+ }, [isOpen, value]);
+
+ const handleClose = () => {
+ setDraftValue(value);
+ onClose();
+ };
+
+ const handleSave = () => {
+ if (onSave(draftValue)) {
+ onClose();
+ }
+ };
+
return (
{onExtract && (
@@ -67,10 +85,10 @@ export const GeminiCommonConfigModal: React.FC<
})}
)}
-
+
{t("common.cancel")}
-
+
{t("common.save")}
@@ -86,8 +104,8 @@ export const GeminiCommonConfigModal: React.FC<
void;
commonConfigSnippet: string;
- onCommonConfigSnippetChange: (value: string) => void;
+ onCommonConfigSnippetChange: (value: string) => boolean;
+ onCommonConfigErrorClear: () => void;
commonConfigError: string;
envError: string;
configError: string;
@@ -29,6 +30,7 @@ const GeminiConfigEditor: React.FC = ({
onCommonConfigToggle,
commonConfigSnippet,
onCommonConfigSnippetChange,
+ onCommonConfigErrorClear,
commonConfigError,
envError,
configError,
@@ -37,12 +39,10 @@ const GeminiConfigEditor: React.FC = ({
}) => {
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
- // Auto-open common config modal if there's an error
- useEffect(() => {
- if (commonConfigError && !isCommonConfigModalOpen) {
- setIsCommonConfigModalOpen(true);
- }
- }, [commonConfigError, isCommonConfigModalOpen]);
+ const handleCloseCommonConfigModal = () => {
+ onCommonConfigErrorClear();
+ setIsCommonConfigModalOpen(false);
+ };
return (
@@ -68,9 +68,9 @@ const GeminiConfigEditor: React.FC
= ({
{/* Common Config Modal */}
setIsCommonConfigModalOpen(false)}
+ onClose={handleCloseCommonConfigModal}
value={commonConfigSnippet}
- onChange={onCommonConfigSnippetChange}
+ onSave={onCommonConfigSnippetChange}
error={commonConfigError}
onExtract={onExtract}
isExtracting={isExtracting}
diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx
index fa237b7c..2f972b27 100644
--- a/src/components/providers/forms/ProviderForm.tsx
+++ b/src/components/providers/forms/ProviderForm.tsx
@@ -447,6 +447,7 @@ export function ProviderForm({
handleCommonConfigSnippetChange: handleCodexCommonConfigSnippetChange,
isExtracting: isCodexExtracting,
handleExtract: handleCodexExtract,
+ clearCommonConfigError: clearCodexCommonConfigError,
} = useCodexCommonConfig({
codexConfig,
onConfigChange: handleCodexConfigChange,
@@ -530,6 +531,7 @@ export function ProviderForm({
handleCommonConfigSnippetChange: handleGeminiCommonConfigSnippetChange,
isExtracting: isGeminiExtracting,
handleExtract: handleGeminiExtract,
+ clearCommonConfigError: clearGeminiCommonConfigError,
} = useGeminiCommonConfig({
envValue: geminiEnv,
onEnvChange: handleGeminiEnvChange,
@@ -1467,6 +1469,7 @@ export function ProviderForm({
onCommonConfigToggle={handleCodexCommonConfigToggle}
commonConfigSnippet={codexCommonConfigSnippet}
onCommonConfigSnippetChange={handleCodexCommonConfigSnippetChange}
+ onCommonConfigErrorClear={clearCodexCommonConfigError}
commonConfigError={codexCommonConfigError}
authError={codexAuthError}
configError={codexConfigError}
@@ -1488,6 +1491,7 @@ export function ProviderForm({
onCommonConfigSnippetChange={
handleGeminiCommonConfigSnippetChange
}
+ onCommonConfigErrorClear={clearGeminiCommonConfigError}
commonConfigError={geminiCommonConfigError}
envError={envError}
configError={geminiConfigError}
diff --git a/src/components/providers/forms/hooks/useCodexCommonConfig.ts b/src/components/providers/forms/hooks/useCodexCommonConfig.ts
index 926902a8..fb65053d 100644
--- a/src/components/providers/forms/hooks/useCodexCommonConfig.ts
+++ b/src/components/providers/forms/hooks/useCodexCommonConfig.ts
@@ -1,10 +1,12 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
+import { parse as parseToml } from "smol-toml";
import {
updateTomlCommonConfigSnippet,
hasTomlCommonConfigSnippet,
} from "@/utils/providerConfigUtils";
import { configApi } from "@/lib/api";
+import { normalizeTomlText } from "@/utils/textNormalization";
const LEGACY_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
const DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config
@@ -53,6 +55,30 @@ export function useCodexCommonConfig({
hasInitializedEditMode.current = false;
}, [selectedPresetId, initialEnabled]);
+ const parseCommonConfigSnippet = useCallback((snippetString: string) => {
+ const trimmed = snippetString.trim();
+ if (!trimmed) {
+ return {
+ hasContent: false,
+ };
+ }
+
+ try {
+ const parsed = parseToml(normalizeTomlText(snippetString)) as Record<
+ string,
+ unknown
+ >;
+ return {
+ hasContent: Object.keys(parsed).length > 0,
+ };
+ } catch (error) {
+ return {
+ hasContent: false,
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+ }, []);
+
// 初始化:从 config.json 加载,支持从 localStorage 迁移
useEffect(() => {
let mounted = true;
@@ -107,36 +133,59 @@ export function useCodexCommonConfig({
// 初始化时检查通用配置片段(编辑模式)
useEffect(() => {
- if (initialData?.settingsConfig && !isLoading) {
- const config =
- typeof initialData.settingsConfig.config === "string"
- ? initialData.settingsConfig.config
- : "";
- const inferredHasCommon = hasTomlCommonConfigSnippet(
- config,
- commonConfigSnippet,
- );
- const hasCommon = initialEnabled ?? inferredHasCommon;
- setUseCommonConfig(hasCommon);
-
- if (hasCommon && !inferredHasCommon && !hasInitializedEditMode.current) {
- hasInitializedEditMode.current = true;
- const { updatedConfig, error } = updateTomlCommonConfigSnippet(
- codexConfig,
- commonConfigSnippet,
- true,
- );
- if (!error) {
- isUpdatingFromCommonConfig.current = true;
- onConfigChange(updatedConfig);
- setTimeout(() => {
- isUpdatingFromCommonConfig.current = false;
- }, 0);
- }
- } else {
- hasInitializedEditMode.current = true;
- }
+ if (
+ !initialData?.settingsConfig ||
+ isLoading ||
+ hasInitializedEditMode.current
+ ) {
+ return;
}
+
+ hasInitializedEditMode.current = true;
+
+ const parsedSnippet = parseCommonConfigSnippet(commonConfigSnippet);
+ if (parsedSnippet.error) {
+ if (commonConfigSnippet.trim()) {
+ setCommonConfigError(parsedSnippet.error);
+ }
+ setUseCommonConfig(false);
+ return;
+ }
+
+ const config =
+ typeof initialData.settingsConfig.config === "string"
+ ? initialData.settingsConfig.config
+ : "";
+ const inferredHasCommon = hasTomlCommonConfigSnippet(
+ config,
+ commonConfigSnippet,
+ );
+ const hasCommon = initialEnabled ?? inferredHasCommon;
+
+ if (hasCommon && !inferredHasCommon) {
+ const { updatedConfig, error } = updateTomlCommonConfigSnippet(
+ codexConfig,
+ commonConfigSnippet,
+ true,
+ );
+ if (error) {
+ setCommonConfigError(error);
+ setUseCommonConfig(false);
+ return;
+ }
+
+ setCommonConfigError("");
+ setUseCommonConfig(true);
+ isUpdatingFromCommonConfig.current = true;
+ onConfigChange(updatedConfig);
+ setTimeout(() => {
+ isUpdatingFromCommonConfig.current = false;
+ }, 0);
+ return;
+ }
+
+ setCommonConfigError("");
+ setUseCommonConfig(hasCommon);
}, [
codexConfig,
commonConfigSnippet,
@@ -144,49 +193,75 @@ export function useCodexCommonConfig({
initialEnabled,
isLoading,
onConfigChange,
+ parseCommonConfigSnippet,
]);
// 新建模式:如果通用配置片段存在且有效,默认启用
useEffect(() => {
- // 仅新建模式、加载完成、尚未初始化过
- if (!initialData && !isLoading && !hasInitializedNewMode.current) {
- hasInitializedNewMode.current = true;
-
- // 检查 TOML 片段是否有实质内容(不只是注释和空行)
- const lines = commonConfigSnippet.split("\n");
- const hasContent = lines.some((line) => {
- const trimmed = line.trim();
- return trimmed && !trimmed.startsWith("#");
- });
-
- if (hasContent) {
- setUseCommonConfig(true);
- // 合并通用配置到当前配置
- const { updatedConfig, error } = updateTomlCommonConfigSnippet(
- codexConfig,
- commonConfigSnippet,
- true,
- );
- if (!error) {
- isUpdatingFromCommonConfig.current = true;
- onConfigChange(updatedConfig);
- setTimeout(() => {
- isUpdatingFromCommonConfig.current = false;
- }, 0);
- }
- }
+ if (initialData || isLoading || hasInitializedNewMode.current) {
+ return;
}
+
+ hasInitializedNewMode.current = true;
+
+ const parsedSnippet = parseCommonConfigSnippet(commonConfigSnippet);
+ if (parsedSnippet.error) {
+ if (commonConfigSnippet.trim()) {
+ setCommonConfigError(parsedSnippet.error);
+ }
+ setUseCommonConfig(false);
+ return;
+ }
+ if (!parsedSnippet.hasContent) {
+ return;
+ }
+
+ const { updatedConfig, error } = updateTomlCommonConfigSnippet(
+ codexConfig,
+ commonConfigSnippet,
+ true,
+ );
+ if (error) {
+ setCommonConfigError(error);
+ setUseCommonConfig(false);
+ return;
+ }
+
+ setCommonConfigError("");
+ setUseCommonConfig(true);
+ isUpdatingFromCommonConfig.current = true;
+ onConfigChange(updatedConfig);
+ setTimeout(() => {
+ isUpdatingFromCommonConfig.current = false;
+ }, 0);
}, [
initialData,
commonConfigSnippet,
isLoading,
codexConfig,
onConfigChange,
+ parseCommonConfigSnippet,
]);
// 处理通用配置开关
const handleCommonConfigToggle = useCallback(
(checked: boolean) => {
+ const parsedSnippet = parseCommonConfigSnippet(commonConfigSnippet);
+ if (parsedSnippet.error) {
+ setCommonConfigError(parsedSnippet.error);
+ setUseCommonConfig(false);
+ return;
+ }
+ if (!parsedSnippet.hasContent) {
+ setCommonConfigError(
+ t("codexConfig.noCommonConfigToApply", {
+ defaultValue: "通用配置片段为空或没有可写入的内容",
+ }),
+ );
+ setUseCommonConfig(false);
+ return;
+ }
+
const { updatedConfig, error: snippetError } =
updateTomlCommonConfigSnippet(
codexConfig,
@@ -210,18 +285,39 @@ export function useCodexCommonConfig({
isUpdatingFromCommonConfig.current = false;
}, 0);
},
- [codexConfig, commonConfigSnippet, onConfigChange],
+ [codexConfig, commonConfigSnippet, onConfigChange, parseCommonConfigSnippet, t],
);
// 处理通用配置片段变化
const handleCommonConfigSnippetChange = useCallback(
- (value: string) => {
+ (value: string): boolean => {
const previousSnippet = commonConfigSnippet;
- setCommonConfigSnippetState(value);
if (!value.trim()) {
setCommonConfigError("");
- // 保存到 config.json(清空)
+
+ if (useCommonConfig) {
+ const previousParsed = parseCommonConfigSnippet(previousSnippet);
+ let updatedConfig = codexConfig;
+
+ if (!previousParsed.error && previousParsed.hasContent) {
+ const removeResult = updateTomlCommonConfigSnippet(
+ codexConfig,
+ previousSnippet,
+ false,
+ );
+ if (removeResult.error) {
+ setCommonConfigError(removeResult.error);
+ return false;
+ }
+ updatedConfig = removeResult.updatedConfig;
+ }
+
+ onConfigChange(updatedConfig);
+ setUseCommonConfig(false);
+ }
+
+ setCommonConfigSnippetState("");
configApi
.setCommonConfigSnippet("codex", "")
.catch((error: unknown) => {
@@ -230,51 +326,42 @@ export function useCodexCommonConfig({
t("codexConfig.saveFailed", { error: String(error) }),
);
});
+ return true;
+ }
- if (useCommonConfig) {
- const { updatedConfig } = updateTomlCommonConfigSnippet(
+ const parsedNextSnippet = parseCommonConfigSnippet(value);
+ if (parsedNextSnippet.error) {
+ setCommonConfigError(parsedNextSnippet.error);
+ return false;
+ }
+
+ // 若当前启用通用配置,需要替换为最新片段
+ if (useCommonConfig) {
+ let nextConfig = codexConfig;
+ const previousParsed = parseCommonConfigSnippet(previousSnippet);
+
+ if (!previousParsed.error && previousParsed.hasContent) {
+ const removeResult = updateTomlCommonConfigSnippet(
codexConfig,
previousSnippet,
false,
);
- onConfigChange(updatedConfig);
- setUseCommonConfig(false);
+ if (removeResult.error) {
+ setCommonConfigError(removeResult.error);
+ return false;
+ }
+ nextConfig = removeResult.updatedConfig;
}
- return;
- }
- // TOML 格式校验较为复杂,暂时不做校验,直接清空错误
- setCommonConfigError("");
- // 保存到 config.json
- configApi
- .setCommonConfigSnippet("codex", value)
- .catch((error: unknown) => {
- console.error("保存 Codex 通用配置失败:", error);
- setCommonConfigError(
- t("codexConfig.saveFailed", { error: String(error) }),
- );
- });
-
- // 若当前启用通用配置,需要替换为最新片段
- if (useCommonConfig) {
- const removeResult = updateTomlCommonConfigSnippet(
- codexConfig,
- previousSnippet,
- false,
- );
- if (removeResult.error) {
- setCommonConfigError(removeResult.error);
- return;
- }
const addResult = updateTomlCommonConfigSnippet(
- removeResult.updatedConfig,
+ nextConfig,
value,
true,
);
if (addResult.error) {
setCommonConfigError(addResult.error);
- return;
+ return false;
}
// 标记正在通过通用配置更新,避免触发状态检查
@@ -285,8 +372,28 @@ export function useCodexCommonConfig({
isUpdatingFromCommonConfig.current = false;
}, 0);
}
+
+ setCommonConfigError("");
+ setCommonConfigSnippetState(value);
+ configApi
+ .setCommonConfigSnippet("codex", value)
+ .catch((error: unknown) => {
+ console.error("保存 Codex 通用配置失败:", error);
+ setCommonConfigError(
+ t("codexConfig.saveFailed", { error: String(error) }),
+ );
+ });
+
+ return true;
},
- [commonConfigSnippet, codexConfig, useCommonConfig, onConfigChange],
+ [
+ commonConfigSnippet,
+ codexConfig,
+ onConfigChange,
+ parseCommonConfigSnippet,
+ t,
+ useCommonConfig,
+ ],
);
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
@@ -294,12 +401,17 @@ export function useCodexCommonConfig({
if (isUpdatingFromCommonConfig.current || isLoading) {
return;
}
+ const parsedSnippet = parseCommonConfigSnippet(commonConfigSnippet);
+ if (parsedSnippet.error) {
+ setUseCommonConfig(false);
+ return;
+ }
const hasCommon = hasTomlCommonConfigSnippet(
codexConfig,
commonConfigSnippet,
);
setUseCommonConfig(hasCommon);
- }, [codexConfig, commonConfigSnippet, isLoading]);
+ }, [codexConfig, commonConfigSnippet, isLoading, parseCommonConfigSnippet]);
// 从编辑器当前内容提取通用配置片段
const handleExtract = useCallback(async () => {
@@ -333,6 +445,10 @@ export function useCodexCommonConfig({
}
}, [codexConfig, t]);
+ const clearCommonConfigError = useCallback(() => {
+ setCommonConfigError("");
+ }, []);
+
return {
useCommonConfig,
commonConfigSnippet,
@@ -342,5 +458,6 @@ export function useCodexCommonConfig({
handleCommonConfigToggle,
handleCommonConfigSnippetChange,
handleExtract,
+ clearCommonConfigError,
};
}
diff --git a/src/components/providers/forms/hooks/useGeminiCommonConfig.ts b/src/components/providers/forms/hooks/useGeminiCommonConfig.ts
index 53a2acbd..9a02a2ce 100644
--- a/src/components/providers/forms/hooks/useGeminiCommonConfig.ts
+++ b/src/components/providers/forms/hooks/useGeminiCommonConfig.ts
@@ -216,43 +216,55 @@ export function useGeminiCommonConfig({
// 初始化时检查通用配置片段(编辑模式)
useEffect(() => {
- if (initialData?.settingsConfig && !isLoading) {
- try {
- const env =
- isPlainObject(initialData.settingsConfig.env) &&
- Object.keys(initialData.settingsConfig.env).length > 0
- ? (initialData.settingsConfig.env as Record)
- : {};
- const parsed = parseSnippetEnv(commonConfigSnippet);
- if (parsed.error) return;
- const inferredHasCommon = hasEnvCommonConfigSnippet(
- env,
- parsed.env as Record,
- );
- const hasCommon = initialEnabled ?? inferredHasCommon;
- setUseCommonConfig(hasCommon);
+ if (
+ !initialData?.settingsConfig ||
+ isLoading ||
+ hasInitializedEditMode.current
+ ) {
+ return;
+ }
- if (
- hasCommon &&
- !inferredHasCommon &&
- !hasInitializedEditMode.current
- ) {
- hasInitializedEditMode.current = true;
- const currentEnv = envStringToObj(envValue);
- const merged = applySnippetToEnv(currentEnv, parsed.env);
- const nextEnvString = envObjToString(merged);
+ hasInitializedEditMode.current = true;
- isUpdatingFromCommonConfig.current = true;
- onEnvChange(nextEnvString);
- setTimeout(() => {
- isUpdatingFromCommonConfig.current = false;
- }, 0);
- } else {
- hasInitializedEditMode.current = true;
+ try {
+ const env =
+ isPlainObject(initialData.settingsConfig.env) &&
+ Object.keys(initialData.settingsConfig.env).length > 0
+ ? (initialData.settingsConfig.env as Record)
+ : {};
+ const parsed = parseSnippetEnv(commonConfigSnippet);
+ if (parsed.error) {
+ if (commonConfigSnippet.trim()) {
+ setCommonConfigError(parsed.error);
}
- } catch {
- // ignore parse error
+ setUseCommonConfig(false);
+ return;
}
+ const inferredHasCommon = hasEnvCommonConfigSnippet(
+ env,
+ parsed.env as Record,
+ );
+ const hasCommon = initialEnabled ?? inferredHasCommon;
+
+ if (hasCommon && !inferredHasCommon) {
+ const currentEnv = envStringToObj(envValue);
+ const merged = applySnippetToEnv(currentEnv, parsed.env);
+ const nextEnvString = envObjToString(merged);
+
+ setCommonConfigError("");
+ setUseCommonConfig(true);
+ isUpdatingFromCommonConfig.current = true;
+ onEnvChange(nextEnvString);
+ setTimeout(() => {
+ isUpdatingFromCommonConfig.current = false;
+ }, 0);
+ return;
+ }
+
+ setCommonConfigError("");
+ setUseCommonConfig(hasCommon);
+ } catch {
+ // ignore parse error
}
}, [
applySnippetToEnv,
@@ -270,26 +282,34 @@ export function useGeminiCommonConfig({
// 新建模式:如果通用配置片段存在且有效,默认启用
useEffect(() => {
- // 仅新建模式、加载完成、尚未初始化过
- if (!initialData && !isLoading && !hasInitializedNewMode.current) {
- hasInitializedNewMode.current = true;
-
- const parsed = parseSnippetEnv(commonConfigSnippet);
- if (parsed.error) return;
- const hasContent = Object.keys(parsed.env).length > 0;
- if (!hasContent) return;
-
- setUseCommonConfig(true);
- const currentEnv = envStringToObj(envValue);
- const merged = applySnippetToEnv(currentEnv, parsed.env);
- const nextEnvString = envObjToString(merged);
-
- isUpdatingFromCommonConfig.current = true;
- onEnvChange(nextEnvString);
- setTimeout(() => {
- isUpdatingFromCommonConfig.current = false;
- }, 0);
+ if (initialData || isLoading || hasInitializedNewMode.current) {
+ return;
}
+
+ hasInitializedNewMode.current = true;
+
+ const parsed = parseSnippetEnv(commonConfigSnippet);
+ if (parsed.error) {
+ if (commonConfigSnippet.trim()) {
+ setCommonConfigError(parsed.error);
+ }
+ setUseCommonConfig(false);
+ return;
+ }
+ const hasContent = Object.keys(parsed.env).length > 0;
+ if (!hasContent) return;
+
+ setCommonConfigError("");
+ setUseCommonConfig(true);
+ const currentEnv = envStringToObj(envValue);
+ const merged = applySnippetToEnv(currentEnv, parsed.env);
+ const nextEnvString = envObjToString(merged);
+
+ isUpdatingFromCommonConfig.current = true;
+ onEnvChange(nextEnvString);
+ setTimeout(() => {
+ isUpdatingFromCommonConfig.current = false;
+ }, 0);
}, [
initialData,
isLoading,
@@ -346,13 +366,29 @@ export function useGeminiCommonConfig({
// 处理通用配置片段变化
const handleCommonConfigSnippetChange = useCallback(
- (value: string) => {
+ (value: string): boolean => {
const previousSnippet = commonConfigSnippet;
- setCommonConfigSnippetState(value);
if (!value.trim()) {
setCommonConfigError("");
- // 保存到 config.json(清空)
+
+ if (useCommonConfig) {
+ const parsedPrevious = parseSnippetEnv(previousSnippet);
+ if (
+ !parsedPrevious.error &&
+ Object.keys(parsedPrevious.env).length > 0
+ ) {
+ const currentEnv = envStringToObj(envValue);
+ const updatedEnv = removeSnippetFromEnv(
+ currentEnv,
+ parsedPrevious.env,
+ );
+ onEnvChange(envObjToString(updatedEnv));
+ }
+ setUseCommonConfig(false);
+ }
+
+ setCommonConfigSnippetState("");
configApi
.setCommonConfigSnippet("gemini", "")
.catch((error: unknown) => {
@@ -361,36 +397,16 @@ export function useGeminiCommonConfig({
t("geminiConfig.saveFailed", { error: String(error) }),
);
});
-
- if (useCommonConfig) {
- const parsed = parseSnippetEnv(previousSnippet);
- if (!parsed.error && Object.keys(parsed.env).length > 0) {
- const currentEnv = envStringToObj(envValue);
- const updatedEnv = removeSnippetFromEnv(currentEnv, parsed.env);
- onEnvChange(envObjToString(updatedEnv));
- }
- setUseCommonConfig(false);
- }
- return;
+ return true;
}
// 校验 JSON 格式
const parsed = parseSnippetEnv(value);
if (parsed.error) {
setCommonConfigError(parsed.error);
- return;
+ return false;
}
- setCommonConfigError("");
- configApi
- .setCommonConfigSnippet("gemini", value)
- .catch((error: unknown) => {
- console.error("保存 Gemini 通用配置失败:", error);
- setCommonConfigError(
- t("geminiConfig.saveFailed", { error: String(error) }),
- );
- });
-
// 若当前启用通用配置,需要替换为最新片段
if (useCommonConfig) {
const prevParsed = parseSnippetEnv(previousSnippet);
@@ -413,6 +429,19 @@ export function useGeminiCommonConfig({
isUpdatingFromCommonConfig.current = false;
}, 0);
}
+
+ setCommonConfigError("");
+ setCommonConfigSnippetState(value);
+ configApi
+ .setCommonConfigSnippet("gemini", value)
+ .catch((error: unknown) => {
+ console.error("保存 Gemini 通用配置失败:", error);
+ setCommonConfigError(
+ t("geminiConfig.saveFailed", { error: String(error) }),
+ );
+ });
+
+ return true;
},
[
applySnippetToEnv,
@@ -487,6 +516,10 @@ export function useGeminiCommonConfig({
}
}, [envStringToObj, envValue, parseSnippetEnv, t]);
+ const clearCommonConfigError = useCallback(() => {
+ setCommonConfigError("");
+ }, []);
+
return {
useCommonConfig,
commonConfigSnippet,
@@ -496,5 +529,6 @@ export function useGeminiCommonConfig({
handleCommonConfigToggle,
handleCommonConfigSnippetChange,
handleExtract,
+ clearCommonConfigError,
};
}
diff --git a/tests/components/CommonConfigModalBehavior.test.tsx b/tests/components/CommonConfigModalBehavior.test.tsx
new file mode 100644
index 00000000..76abf6a1
--- /dev/null
+++ b/tests/components/CommonConfigModalBehavior.test.tsx
@@ -0,0 +1,121 @@
+import type { ReactNode } from "react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import CodexConfigEditor from "@/components/providers/forms/CodexConfigEditor";
+import GeminiConfigEditor from "@/components/providers/forms/GeminiConfigEditor";
+
+vi.mock("@/components/common/FullScreenPanel", () => ({
+ FullScreenPanel: ({
+ isOpen,
+ title,
+ onClose,
+ children,
+ footer,
+ }: {
+ isOpen: boolean;
+ title: string;
+ onClose: () => void;
+ children: ReactNode;
+ footer?: ReactNode;
+ }) =>
+ isOpen ? (
+
+
+ panel-close
+
+
{title}
+
{children}
+
{footer}
+
+ ) : null,
+}));
+
+vi.mock("@/components/JsonEditor", () => ({
+ default: ({
+ value,
+ onChange,
+ }: {
+ value: string;
+ onChange: (value: string) => void;
+ }) => (
+