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 = ({ })} )} - - @@ -87,8 +105,8 @@ 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< })} )} - - @@ -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 ? ( +
+ +

{title}

+
{children}
+
{footer}
+
+ ) : null, +})); + +vi.mock("@/components/JsonEditor", () => ({ + default: ({ + value, + onChange, + }: { + value: string; + onChange: (value: string) => void; + }) => ( +